@misterhuydo/sentinel 1.4.67 → 1.4.69

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.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """Fix the broken slack_alert block in chain_release handler."""
3
+ import ast, py_compile, tempfile, os, sys
4
+
5
+ TARGET = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
6
+
7
+ with open(TARGET, 'r', encoding='utf-8') as f:
8
+ lines = f.readlines()
9
+
10
+ # Find the broken block: look for doubled slack_alert lines near "Chain release"
11
+ # Strategy: find line with "all_ok = all(r.get" and replace everything up to
12
+ # "logger.info("Boss chain_release"
13
+
14
+ start_idx = None
15
+ end_idx = None
16
+ for i, line in enumerate(lines):
17
+ if 'all_ok = all(r.get("status") in ("released", "no_cicd")' in line:
18
+ start_idx = i
19
+ if start_idx and 'logger.info("Boss chain_release' in line:
20
+ end_idx = i
21
+ break
22
+
23
+ if start_idx is None or end_idx is None:
24
+ print(f"Could not find block: start={start_idx}, end={end_idx}")
25
+ # Print context
26
+ for i, line in enumerate(lines):
27
+ if 'chain_release' in line or 'all_ok' in line:
28
+ print(f"{i+1}: {line}", end='')
29
+ sys.exit(1)
30
+
31
+ print(f"Replacing lines {start_idx+1}..{end_idx} (inclusive of logger line)")
32
+
33
+ # Build the replacement lines (8 spaces indent = inside if name == "chain_release" block)
34
+ replacement = '''\
35
+ all_ok = all(r.get("status") in ("released", "no_cicd") for r in results)
36
+ final = "completed" if all_ok else "partial" if results else "failed"
37
+ _icon = ":white_check_mark:" if all_ok else ":warning:"
38
+ _steps = ", ".join(
39
+ "`" + r["repo"] + "` " + ("\u2713" if r.get("status") in ("released", "no_cicd") else "\u2717")
40
+ for r in results
41
+ )
42
+ slack_alert(
43
+ cfg.slack_bot_token, cfg.slack_channel,
44
+ f"{_icon}: *Chain release {final}* \u2014 {_steps}",
45
+ )
46
+ '''
47
+
48
+ # Replace lines start_idx..end_idx-1 (keep the logger line)
49
+ new_lines = lines[:start_idx] + [replacement] + lines[end_idx:]
50
+
51
+ with open(TARGET, 'w', encoding='utf-8') as f:
52
+ f.writelines(new_lines)
53
+
54
+ print("Replacement done.")
55
+
56
+ # Syntax check
57
+ with open(TARGET, 'r') as f:
58
+ src = f.read()
59
+ try:
60
+ ast.parse(src)
61
+ print("Syntax OK")
62
+ except SyntaxError as e:
63
+ print(f"SyntaxError line {e.lineno}: {e.msg}")
64
+ ls = src.splitlines()
65
+ for i in range(max(0, e.lineno-5), min(len(ls), e.lineno+3)):
66
+ print(f"{i+1}: {ls[i]}")
67
+ sys.exit(1)
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Fix Python 3.9 f-string backslash issue in chain_release handler."""
3
+
4
+ TARGET = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
5
+
6
+ with open(TARGET, 'r', encoding='utf-8') as f:
7
+ content = f.read()
8
+
9
+ # Replace the problematic f-string block
10
+ old = (
11
+ " f\":{'white_check_mark' if all_ok else 'warning'}: *Chain release {final}* \\u2014 \"\n"
12
+ " + \", \".join(\n"
13
+ " f\"`{r['repo']}` {'\\u2713' if r.get('status') in ('released', 'no_cicd') else '\\u2717'}\"\n"
14
+ " for r in results\n"
15
+ " ),"
16
+ )
17
+
18
+ new = (
19
+ " (\":white_check_mark:\" if all_ok else \":warning:\") + f\": *Chain release {final}* \\u2014 \"\n"
20
+ " + \", \".join(\n"
21
+ " \"`\" + r[\"repo\"] + \"` \" + (\"\\u2713\" if r.get(\"status\") in (\"released\", \"no_cicd\") else \"\\u2717\")\n"
22
+ " for r in results\n"
23
+ " ),"
24
+ )
25
+
26
+ if old not in content:
27
+ # Try finding it
28
+ idx = content.find("white_check_mark' if all_ok")
29
+ if idx >= 0:
30
+ print("Context around match point:")
31
+ print(repr(content[idx-10:idx+300]))
32
+ else:
33
+ print("Pattern not found at all")
34
+ import sys; sys.exit(1)
35
+
36
+ content = content.replace(old, new, 1)
37
+
38
+ with open(TARGET, 'w', encoding='utf-8') as f:
39
+ f.write(content)
40
+
41
+ print("Fixed f-string.")
42
+
43
+ import ast, py_compile, tempfile, os
44
+ with tempfile.NamedTemporaryFile(suffix='.py', delete=False, mode='w') as tmp:
45
+ tmp.write(content)
46
+ tmp_name = tmp.name
47
+ try:
48
+ py_compile.compile(tmp_name, doraise=True)
49
+ print("Syntax OK")
50
+ finally:
51
+ os.unlink(tmp_name)
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Add a knowledge_cache table + methods to state_store.py and a cache layer
4
+ to ask_codebase in sentinel_boss.py.
5
+
6
+ Concept:
7
+ - When ask_codebase answers a question, save the answer to knowledge_cache
8
+ keyed by (repo_name, normalised_question), TTL 24h
9
+ - On the next identical/similar question, answer from cache instantly
10
+ - Cache is auto-invalidated when a Sentinel fix is applied to that repo
11
+ - New Boss tool: refresh_knowledge — clear cached answers for a repo or all
12
+ - System prompt updated to describe the knowledge cache
13
+ """
14
+ import ast, hashlib, sys
15
+
16
+ STORE = '/home/sentinel/sentinel/code/sentinel/state_store.py'
17
+ BOSS = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
18
+
19
+ # ── 1. Add knowledge_cache to state_store ────────────────────────────────────
20
+
21
+ with open(STORE, 'r', encoding='utf-8') as f:
22
+ store_src = f.read()
23
+
24
+ OLD_MIGRATE_LIST = ''' ("create_pull_requests",'''
25
+
26
+ if OLD_MIGRATE_LIST not in store_src:
27
+ print("ERROR: migrations anchor not found in state_store")
28
+ sys.exit(1)
29
+
30
+ KNOWLEDGE_MIGRATION = ''' ("create_knowledge_cache",
31
+ """CREATE TABLE IF NOT EXISTS knowledge_cache (
32
+ cache_key TEXT PRIMARY KEY,
33
+ repo_name TEXT NOT NULL,
34
+ question TEXT NOT NULL,
35
+ answer TEXT NOT NULL,
36
+ source_tool TEXT DEFAULT 'ask_codebase',
37
+ cached_at TEXT NOT NULL,
38
+ expires_at TEXT NOT NULL,
39
+ hit_count INTEGER DEFAULT 0
40
+ )"""),
41
+ ("create_pull_requests",'''
42
+
43
+ store_src = store_src.replace(OLD_MIGRATE_LIST, KNOWLEDGE_MIGRATION, 1)
44
+ print("Step 1a OK: knowledge_cache migration entry added")
45
+
46
+ # Insert methods before get_all_errors
47
+ KNOWLEDGE_METHODS = ''' # ── Knowledge cache (stable project Q&A) ────────────────────────────────
48
+
49
+ @staticmethod
50
+ def _knowledge_key(repo_name: str, question: str) -> str:
51
+ """Stable hash key for a (repo, question) pair."""
52
+ import re as _re
53
+ normalised = _re.sub(r"[^a-z0-9 ]", " ", question.lower().strip())
54
+ normalised = " ".join(normalised.split())[:200]
55
+ raw = f"{repo_name.lower()}::{normalised}"
56
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
57
+
58
+ def get_knowledge(self, repo_name: str, question: str) -> str | None:
59
+ """Return a cached answer if it exists and has not expired."""
60
+ key = self._knowledge_key(repo_name, question)
61
+ now = _now()
62
+ with self._conn() as conn:
63
+ row = conn.execute(
64
+ "SELECT answer, expires_at, hit_count FROM knowledge_cache "
65
+ "WHERE cache_key=? AND expires_at > ?",
66
+ (key, now),
67
+ ).fetchone()
68
+ if row:
69
+ conn.execute(
70
+ "UPDATE knowledge_cache SET hit_count=? WHERE cache_key=?",
71
+ (row["hit_count"] + 1, key),
72
+ )
73
+ return row["answer"]
74
+ return None
75
+
76
+ def save_knowledge(self, repo_name: str, question: str, answer: str,
77
+ ttl_hours: int = 24, source_tool: str = "ask_codebase") -> None:
78
+ """Cache an answer. Overwrites any existing entry for this key."""
79
+ from datetime import timedelta
80
+ key = self._knowledge_key(repo_name, question)
81
+ now = _now()
82
+ expires = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
83
+ with self._conn() as conn:
84
+ conn.execute(
85
+ "INSERT OR REPLACE INTO knowledge_cache "
86
+ "(cache_key, repo_name, question, answer, source_tool, cached_at, expires_at, hit_count) "
87
+ "VALUES (?,?,?,?,?,?,?,0)",
88
+ (key, repo_name, question, answer, source_tool, now, expires),
89
+ )
90
+
91
+ def invalidate_knowledge(self, repo_name: str = "") -> int:
92
+ """Invalidate cached knowledge. Pass repo_name to clear one repo, or '' to clear all."""
93
+ with self._conn() as conn:
94
+ if repo_name:
95
+ cur = conn.execute(
96
+ "DELETE FROM knowledge_cache WHERE repo_name LIKE ?",
97
+ (f"%{repo_name}%",),
98
+ )
99
+ else:
100
+ cur = conn.execute("DELETE FROM knowledge_cache")
101
+ return cur.rowcount
102
+
103
+ def list_knowledge(self, repo_name: str = "") -> list[dict]:
104
+ """List cached knowledge entries (for admin inspection)."""
105
+ with self._conn() as conn:
106
+ if repo_name:
107
+ rows = conn.execute(
108
+ "SELECT repo_name, question, cached_at, expires_at, hit_count, source_tool "
109
+ "FROM knowledge_cache WHERE repo_name LIKE ? ORDER BY hit_count DESC",
110
+ (f"%{repo_name}%",),
111
+ ).fetchall()
112
+ else:
113
+ rows = conn.execute(
114
+ "SELECT repo_name, question, cached_at, expires_at, hit_count, source_tool "
115
+ "FROM knowledge_cache ORDER BY hit_count DESC LIMIT 100"
116
+ ).fetchall()
117
+ return [dict(r) for r in rows]
118
+
119
+ '''
120
+
121
+ STORE_ANCHOR = ''' def get_all_errors(self, hours: int = 0) -> list[dict]:'''
122
+ if STORE_ANCHOR not in store_src:
123
+ print("ERROR: get_all_errors anchor not found in state_store")
124
+ sys.exit(1)
125
+
126
+ # Also need to import hashlib at the top of state_store.py
127
+ if 'import hashlib' not in store_src:
128
+ store_src = store_src.replace(
129
+ 'import sqlite3\n',
130
+ 'import hashlib\nimport sqlite3\n',
131
+ 1
132
+ )
133
+ print("Step 1b OK: hashlib import added")
134
+
135
+ store_src = store_src.replace(STORE_ANCHOR, KNOWLEDGE_METHODS + STORE_ANCHOR, 1)
136
+ print("Step 1c OK: knowledge_cache methods added to state_store")
137
+
138
+ with open(STORE, 'w', encoding='utf-8') as f:
139
+ f.write(store_src)
140
+
141
+ try:
142
+ ast.parse(store_src)
143
+ print("state_store.py Syntax OK")
144
+ except SyntaxError as e:
145
+ lines = store_src.splitlines()
146
+ print(f"SyntaxError at line {e.lineno}: {e.msg}")
147
+ for i in range(max(0, e.lineno-4), min(len(lines), e.lineno+3)):
148
+ print(f" {i+1}: {lines[i]}")
149
+ sys.exit(1)
150
+
151
+ # ── 2. Update ask_codebase handler in sentinel_boss.py ───────────────────────
152
+
153
+ with open(BOSS, 'r', encoding='utf-8') as f:
154
+ boss = f.read()
155
+
156
+ # Wrap _ask_one call with cache check/store
157
+ OLD_ASK_ONE_CALL = ''' if len(matched) == 1:
158
+ result = _ask_one(*matched[0])
159
+ # Unwrap single-repo result for cleaner response
160
+ return json.dumps(result)
161
+
162
+ # Multiple repos — query each and combine
163
+ results = [_ask_one(rn, r) for rn, r in matched]
164
+ return json.dumps({"project": target, "repos_queried": len(results), "results": results})'''
165
+
166
+ NEW_ASK_ONE_CALL = ''' def _ask_cached(repo_name, repo_cfg) -> dict:
167
+ # Check knowledge cache first (skip for issues mode — always fresh)
168
+ if mode != "issues":
169
+ cached = store.get_knowledge(repo_name, question)
170
+ if cached:
171
+ logger.info("Boss ask_codebase %s: cache hit", repo_name)
172
+ return {"repo": repo_name, "answer": cached, "cached": True}
173
+ result = _ask_one(repo_name, repo_cfg)
174
+ # Save to cache if we got a useful answer
175
+ if result.get("answer") and len(result["answer"]) > 50 and mode != "issues":
176
+ store.save_knowledge(repo_name, question, result["answer"], ttl_hours=24)
177
+ return result
178
+
179
+ if len(matched) == 1:
180
+ result = _ask_cached(*matched[0])
181
+ return json.dumps(result)
182
+
183
+ # Multiple repos — query each and combine
184
+ results = [_ask_cached(rn, r) for rn, r in matched]
185
+ return json.dumps({"project": target, "repos_queried": len(results), "results": results})'''
186
+
187
+ if OLD_ASK_ONE_CALL not in boss:
188
+ print("ERROR: ask_codebase call block not found in boss")
189
+ sys.exit(1)
190
+ boss = boss.replace(OLD_ASK_ONE_CALL, NEW_ASK_ONE_CALL, 1)
191
+ print("Step 2 OK: ask_codebase now uses knowledge cache")
192
+
193
+ # ── 3. Invalidate knowledge cache when a fix is applied ──────────────────────
194
+ # Find where record_fix is called after a successful fix application
195
+
196
+ OLD_RECORD_FIX = ''' if merge_resp.status_code == 200:
197
+ # Success
198
+ sha = merge_resp.json().get("sha", "")[:8]
199
+ store.record_fix(fp, "applied", branch=branch, pr_url=pr_url,
200
+ repo_name=repo_name, commit_hash=sha)
201
+ try:
202
+ store.record_pr_decision(repo_name, int(pr_number), "merged", user_id)
203
+ except Exception:
204
+ pass # PR may not be in tracking table yet — non-fatal'''
205
+
206
+ NEW_RECORD_FIX = ''' if merge_resp.status_code == 200:
207
+ # Success
208
+ sha = merge_resp.json().get("sha", "")[:8]
209
+ store.record_fix(fp, "applied", branch=branch, pr_url=pr_url,
210
+ repo_name=repo_name, commit_hash=sha)
211
+ try:
212
+ store.record_pr_decision(repo_name, int(pr_number), "merged", user_id)
213
+ except Exception:
214
+ pass # PR may not be in tracking table yet — non-fatal
215
+ # Invalidate knowledge cache so next ask_codebase reflects the fix
216
+ try:
217
+ store.invalidate_knowledge(repo_name)
218
+ except Exception:
219
+ pass'''
220
+
221
+ if OLD_RECORD_FIX not in boss:
222
+ print("WARNING: merge_pr Path B success block not found — cache invalidation on merge skipped")
223
+ else:
224
+ boss = boss.replace(OLD_RECORD_FIX, NEW_RECORD_FIX, 1)
225
+ print("Step 3 OK: knowledge cache invalidated on merge")
226
+
227
+ # ── 4. Add refresh_knowledge tool definition ──────────────────────────────────
228
+
229
+ OLD_BEFORE_MERGE_DEF_2 = ''' {
230
+ "name": "list_prs",'''
231
+
232
+ NEW_BEFORE_MERGE_DEF_2 = ''' {
233
+ "name": "refresh_knowledge",
234
+ "description": (
235
+ "Clear the knowledge cache so the next ask_codebase question fetches a fresh answer "
236
+ "instead of using the cached one. Use when the codebase has changed and you want "
237
+ "up-to-date answers. "
238
+ "e.g. 'refresh knowledge for TypeLib', 'clear codebase cache', "
239
+ "'force fresh answer for Java-SDK', 'list what is cached'"
240
+ ),
241
+ "input_schema": {
242
+ "type": "object",
243
+ "properties": {
244
+ "repo": {
245
+ "type": "string",
246
+ "description": "Repo or project name to clear cache for. Omit to clear all cached knowledge.",
247
+ },
248
+ "list_only": {
249
+ "type": "boolean",
250
+ "description": "If true, just list what is cached without deleting anything.",
251
+ },
252
+ },
253
+ },
254
+ },
255
+ {
256
+ "name": "list_prs",'''
257
+
258
+ if OLD_BEFORE_MERGE_DEF_2 not in boss:
259
+ print("ERROR: anchor before list_prs not found")
260
+ sys.exit(1)
261
+ boss = boss.replace(OLD_BEFORE_MERGE_DEF_2, NEW_BEFORE_MERGE_DEF_2, 1)
262
+ print("Step 4 OK: refresh_knowledge tool definition added")
263
+
264
+ # ── 5. Add refresh_knowledge handler ──────────────────────────────────────────
265
+
266
+ OLD_LIST_PRS_HANDLER = ''' if name == "list_prs":
267
+ if not is_admin:
268
+ return json.dumps({"error": "list_prs is admin-only. Ask a Sentinel admin (SLACK_ADMIN_USERS) to run this for you."})'''
269
+
270
+ NEW_LIST_PRS_HANDLER = ''' if name == "refresh_knowledge":
271
+ repo_filter = inputs.get("repo", "").strip()
272
+ list_only = bool(inputs.get("list_only", False))
273
+ if list_only:
274
+ entries = store.list_knowledge(repo_name=repo_filter)
275
+ if not entries:
276
+ return json.dumps({"message": "Knowledge cache is empty.", "entries": []})
277
+ return json.dumps({
278
+ "total": len(entries),
279
+ "entries": [
280
+ {
281
+ "repo": e["repo_name"],
282
+ "question": e["question"][:80],
283
+ "cached_at": e["cached_at"][:16],
284
+ "expires_at": e["expires_at"][:16],
285
+ "hits": e["hit_count"],
286
+ "tool": e["source_tool"],
287
+ }
288
+ for e in entries
289
+ ],
290
+ })
291
+ deleted = store.invalidate_knowledge(repo_name=repo_filter)
292
+ scope = f"repo '{repo_filter}'" if repo_filter else "all repos"
293
+ logger.info("Boss refresh_knowledge: cleared %d entries for %s by %s", deleted, scope, user_id)
294
+ return json.dumps({
295
+ "status": "cleared",
296
+ "deleted": deleted,
297
+ "scope": scope,
298
+ "note": "Next ask_codebase call will fetch a fresh answer from Claude.",
299
+ })
300
+
301
+ if name == "list_prs":
302
+ if not is_admin:
303
+ return json.dumps({"error": "list_prs is admin-only. Ask a Sentinel admin (SLACK_ADMIN_USERS) to run this for you."})'''
304
+
305
+ if OLD_LIST_PRS_HANDLER not in boss:
306
+ print("ERROR: list_prs handler anchor not found")
307
+ sys.exit(1)
308
+ boss = boss.replace(OLD_LIST_PRS_HANDLER, NEW_LIST_PRS_HANDLER, 1)
309
+ print("Step 5 OK: refresh_knowledge handler added")
310
+
311
+ with open(BOSS, 'w', encoding='utf-8') as f:
312
+ f.write(boss)
313
+ print("Written OK")
314
+
315
+ try:
316
+ ast.parse(boss)
317
+ print("sentinel_boss.py Syntax OK")
318
+ except SyntaxError as e:
319
+ lines = boss.splitlines()
320
+ print(f"SyntaxError at line {e.lineno}: {e.msg}")
321
+ for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
322
+ print(f" {i+1}: {lines[i]}")
323
+ sys.exit(1)