@misterhuydo/sentinel 1.4.68 → 1.4.70

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,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)
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Make the knowledge cache commit-aware:
4
+ - Store the repo's HEAD commit SHA when caching an answer
5
+ - In get_knowledge, if a current_sha is passed and differs from stored sha → cache miss
6
+ - In ask_codebase handler, read git HEAD before checking cache
7
+ - Also: background refresh — _pr_check_loop detects HEAD changes and invalidates cache
8
+ """
9
+ import ast, sys
10
+
11
+ STORE = '/home/sentinel/sentinel/code/sentinel/state_store.py'
12
+ BOSS = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
13
+
14
+ # ── 1. Add commit_hash column to knowledge_cache migration ────────────────────
15
+
16
+ with open(STORE, 'r', encoding='utf-8') as f:
17
+ store_src = f.read()
18
+
19
+ OLD_KC_TABLE = ''' """CREATE TABLE IF NOT EXISTS knowledge_cache (
20
+ cache_key TEXT PRIMARY KEY,
21
+ repo_name TEXT NOT NULL,
22
+ question TEXT NOT NULL,
23
+ answer TEXT NOT NULL,
24
+ source_tool TEXT DEFAULT 'ask_codebase',
25
+ cached_at TEXT NOT NULL,
26
+ expires_at TEXT NOT NULL,
27
+ hit_count INTEGER DEFAULT 0
28
+ )"""),'''
29
+
30
+ NEW_KC_TABLE = ''' """CREATE TABLE IF NOT EXISTS knowledge_cache (
31
+ cache_key TEXT PRIMARY KEY,
32
+ repo_name TEXT NOT NULL,
33
+ question TEXT NOT NULL,
34
+ answer TEXT NOT NULL,
35
+ source_tool TEXT DEFAULT 'ask_codebase',
36
+ cached_at TEXT NOT NULL,
37
+ expires_at TEXT NOT NULL,
38
+ hit_count INTEGER DEFAULT 0,
39
+ commit_hash TEXT DEFAULT ''
40
+ )"""),
41
+ ("add_knowledge_commit_hash",
42
+ "ALTER TABLE knowledge_cache ADD COLUMN commit_hash TEXT DEFAULT ''"),'''
43
+
44
+ if OLD_KC_TABLE not in store_src:
45
+ print("ERROR: knowledge_cache table definition not found")
46
+ sys.exit(1)
47
+ store_src = store_src.replace(OLD_KC_TABLE, NEW_KC_TABLE, 1)
48
+ print("Step 1 OK: commit_hash column added to knowledge_cache")
49
+
50
+ # ── 2. Update get_knowledge to accept and check current_sha ───────────────────
51
+
52
+ OLD_GET_KNOWLEDGE = ''' def get_knowledge(self, repo_name: str, question: str) -> str | None:
53
+ """Return a cached answer if it exists and has not expired."""
54
+ key = self._knowledge_key(repo_name, question)
55
+ now = _now()
56
+ with self._conn() as conn:
57
+ row = conn.execute(
58
+ "SELECT answer, expires_at, hit_count FROM knowledge_cache "
59
+ "WHERE cache_key=? AND expires_at > ?",
60
+ (key, now),
61
+ ).fetchone()
62
+ if row:
63
+ conn.execute(
64
+ "UPDATE knowledge_cache SET hit_count=? WHERE cache_key=?",
65
+ (row["hit_count"] + 1, key),
66
+ )
67
+ return row["answer"]
68
+ return None'''
69
+
70
+ NEW_GET_KNOWLEDGE = ''' def get_knowledge(self, repo_name: str, question: str,
71
+ current_sha: str = "") -> str | None:
72
+ """Return a cached answer if it exists, not expired, and commit hash matches."""
73
+ key = self._knowledge_key(repo_name, question)
74
+ now = _now()
75
+ with self._conn() as conn:
76
+ row = conn.execute(
77
+ "SELECT answer, expires_at, hit_count, commit_hash FROM knowledge_cache "
78
+ "WHERE cache_key=? AND expires_at > ?",
79
+ (key, now),
80
+ ).fetchone()
81
+ if not row:
82
+ return None
83
+ # If we know the current HEAD, verify cache was made from same commit
84
+ stored_sha = row["commit_hash"] or ""
85
+ if current_sha and stored_sha and current_sha != stored_sha:
86
+ logger.debug("Knowledge cache stale for %s (sha %s→%s)",
87
+ repo_name, stored_sha[:8], current_sha[:8])
88
+ conn.execute("DELETE FROM knowledge_cache WHERE cache_key=?", (key,))
89
+ return None # stale — fetch fresh
90
+ conn.execute(
91
+ "UPDATE knowledge_cache SET hit_count=? WHERE cache_key=?",
92
+ (row["hit_count"] + 1, key),
93
+ )
94
+ return row["answer"]'''
95
+
96
+ if OLD_GET_KNOWLEDGE not in store_src:
97
+ print("ERROR: get_knowledge method not found")
98
+ sys.exit(1)
99
+ store_src = store_src.replace(OLD_GET_KNOWLEDGE, NEW_GET_KNOWLEDGE, 1)
100
+ print("Step 2 OK: get_knowledge checks commit_hash")
101
+
102
+ # ── 3. Update save_knowledge to store commit_hash ─────────────────────────────
103
+
104
+ OLD_SAVE_KNOWLEDGE = ''' def save_knowledge(self, repo_name: str, question: str, answer: str,
105
+ ttl_hours: int = 24, source_tool: str = "ask_codebase") -> None:
106
+ """Cache an answer. Overwrites any existing entry for this key."""
107
+ from datetime import timedelta
108
+ key = self._knowledge_key(repo_name, question)
109
+ now = _now()
110
+ expires = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
111
+ with self._conn() as conn:
112
+ conn.execute(
113
+ "INSERT OR REPLACE INTO knowledge_cache "
114
+ "(cache_key, repo_name, question, answer, source_tool, cached_at, expires_at, hit_count) "
115
+ "VALUES (?,?,?,?,?,?,?,0)",
116
+ (key, repo_name, question, answer, source_tool, now, expires),
117
+ )'''
118
+
119
+ NEW_SAVE_KNOWLEDGE = ''' def save_knowledge(self, repo_name: str, question: str, answer: str,
120
+ ttl_hours: int = 24, source_tool: str = "ask_codebase",
121
+ commit_hash: str = "") -> None:
122
+ """Cache an answer, optionally tagging it with the repo's HEAD SHA."""
123
+ from datetime import timedelta
124
+ key = self._knowledge_key(repo_name, question)
125
+ now = _now()
126
+ expires = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
127
+ with self._conn() as conn:
128
+ conn.execute(
129
+ "INSERT OR REPLACE INTO knowledge_cache "
130
+ "(cache_key, repo_name, question, answer, source_tool, "
131
+ " cached_at, expires_at, hit_count, commit_hash) "
132
+ "VALUES (?,?,?,?,?,?,?,0,?)",
133
+ (key, repo_name, question, answer, source_tool, now, expires, commit_hash),
134
+ )'''
135
+
136
+ if OLD_SAVE_KNOWLEDGE not in store_src:
137
+ print("ERROR: save_knowledge method not found")
138
+ sys.exit(1)
139
+ store_src = store_src.replace(OLD_SAVE_KNOWLEDGE, NEW_SAVE_KNOWLEDGE, 1)
140
+ print("Step 3 OK: save_knowledge stores commit_hash")
141
+
142
+ with open(STORE, 'w', encoding='utf-8') as f:
143
+ f.write(store_src)
144
+ try:
145
+ ast.parse(store_src)
146
+ print("state_store.py Syntax OK")
147
+ except SyntaxError as e:
148
+ lines = store_src.splitlines()
149
+ print(f"SyntaxError line {e.lineno}: {e.msg}")
150
+ for i in range(max(0, e.lineno-4), min(len(lines), e.lineno+3)):
151
+ print(f" {i+1}: {lines[i]}")
152
+ sys.exit(1)
153
+
154
+ # ── 4. Update ask_codebase to pass git HEAD to cache layer ────────────────────
155
+
156
+ with open(BOSS, 'r', encoding='utf-8') as f:
157
+ boss = f.read()
158
+
159
+ OLD_ASK_CACHED = ''' def _ask_cached(repo_name, repo_cfg) -> dict:
160
+ # Check knowledge cache first (skip for issues mode — always fresh)
161
+ if mode != "issues":
162
+ cached = store.get_knowledge(repo_name, question)
163
+ if cached:
164
+ logger.info("Boss ask_codebase %s: cache hit", repo_name)
165
+ return {"repo": repo_name, "answer": cached, "cached": True}
166
+ result = _ask_one(repo_name, repo_cfg)
167
+ # Save to cache if we got a useful answer
168
+ if result.get("answer") and len(result["answer"]) > 50 and mode != "issues":
169
+ store.save_knowledge(repo_name, question, result["answer"], ttl_hours=24)
170
+ return result'''
171
+
172
+ NEW_ASK_CACHED = ''' def _get_head_sha(repo_cfg) -> str:
173
+ """Get the current HEAD commit SHA from the local clone (fast, no network)."""
174
+ try:
175
+ import subprocess as _sp
176
+ r = _sp.run(
177
+ ["git", "rev-parse", "--short", "HEAD"],
178
+ cwd=str(Path(repo_cfg.local_path)),
179
+ capture_output=True, text=True, timeout=5,
180
+ )
181
+ return r.stdout.strip() if r.returncode == 0 else ""
182
+ except Exception:
183
+ return ""
184
+
185
+ def _ask_cached(repo_name, repo_cfg) -> dict:
186
+ # Check knowledge cache first (skip for issues mode — always fresh)
187
+ if mode != "issues":
188
+ current_sha = _get_head_sha(repo_cfg)
189
+ cached = store.get_knowledge(repo_name, question, current_sha=current_sha)
190
+ if cached:
191
+ logger.info("Boss ask_codebase %s: cache hit (sha=%s)", repo_name, current_sha[:8] if current_sha else "?")
192
+ return {"repo": repo_name, "answer": cached, "cached": True}
193
+ else:
194
+ current_sha = ""
195
+ result = _ask_one(repo_name, repo_cfg)
196
+ # Save to cache if we got a useful answer
197
+ if result.get("answer") and len(result["answer"]) > 50 and mode != "issues":
198
+ store.save_knowledge(repo_name, question, result["answer"],
199
+ ttl_hours=24, commit_hash=current_sha)
200
+ return result'''
201
+
202
+ if OLD_ASK_CACHED not in boss:
203
+ print("ERROR: _ask_cached block not found in boss")
204
+ sys.exit(1)
205
+ boss = boss.replace(OLD_ASK_CACHED, NEW_ASK_CACHED, 1)
206
+ print("Step 4 OK: ask_codebase passes HEAD SHA to cache")
207
+
208
+ # ── 5. Add HEAD-change detection to _pr_check_loop ────────────────────────────
209
+
210
+ with open(BOSS, 'w', encoding='utf-8') as f:
211
+ f.write(boss)
212
+ try:
213
+ ast.parse(boss)
214
+ print("sentinel_boss.py Syntax OK (steps 1-4)")
215
+ except SyntaxError as e:
216
+ lines = boss.splitlines()
217
+ print(f"SyntaxError line {e.lineno}: {e.msg}")
218
+ sys.exit(1)
219
+
220
+ # ── 5 continues in main.py ────────────────────────────────────────────────────
221
+ MAIN = '/home/sentinel/sentinel/code/sentinel/main.py'
222
+ with open(MAIN, 'r', encoding='utf-8') as f:
223
+ boss = f.read()
224
+
225
+ OLD_CLOSE_PRS = ''' # Mark PRs in DB that are no longer open on GitHub
226
+ all_tracked = store.get_prs(repo_name=repo_name, state="open")
227
+ for tracked in all_tracked:
228
+ if tracked["pr_number"] not in seen_nums:
229
+ store.close_pr(repo_name, tracked["pr_number"], state="closed")
230
+ logger.info("PR check: closed PR %s #%d (no longer open on GitHub)",
231
+ repo_name, tracked["pr_number"])
232
+
233
+ except Exception as _e:
234
+ logger.debug("PR check error for %s: %s", repo_name, _e)
235
+ continue'''
236
+
237
+ NEW_CLOSE_PRS = ''' # Mark PRs in DB that are no longer open on GitHub
238
+ all_tracked = store.get_prs(repo_name=repo_name, state="open")
239
+ for tracked in all_tracked:
240
+ if tracked["pr_number"] not in seen_nums:
241
+ store.close_pr(repo_name, tracked["pr_number"], state="closed")
242
+ logger.info("PR check: closed PR %s #%d (no longer open on GitHub)",
243
+ repo_name, tracked["pr_number"])
244
+
245
+ # Detect HEAD change on default branch → invalidate knowledge cache
246
+ try:
247
+ import subprocess as _sp2
248
+ _lp = getattr(repo_cfg, "local_path", None)
249
+ if _lp:
250
+ _r = _sp2.run(
251
+ ["git", "fetch", "--quiet", "origin"],
252
+ cwd=str(_lp), capture_output=True, timeout=30,
253
+ )
254
+ if _r.returncode == 0:
255
+ _local = _sp2.run(
256
+ ["git", "rev-parse", "--short", "HEAD"],
257
+ cwd=str(_lp), capture_output=True, text=True, timeout=5,
258
+ ).stdout.strip()
259
+ _remote = _sp2.run(
260
+ ["git", "rev-parse", "--short", f"origin/{repo_cfg.branch}"],
261
+ cwd=str(_lp), capture_output=True, text=True, timeout=5,
262
+ ).stdout.strip()
263
+ if _local and _remote and _local != _remote:
264
+ deleted = store.invalidate_knowledge(repo_name)
265
+ if deleted:
266
+ logger.info("PR check: HEAD changed for %s (%s→%s), "
267
+ "invalidated %d knowledge cache entries",
268
+ repo_name, _local, _remote, deleted)
269
+ except Exception as _ce:
270
+ logger.debug("Head-change check failed for %s: %s", repo_name, _ce)
271
+
272
+ except Exception as _e:
273
+ logger.debug("PR check error for %s: %s", repo_name, _e)
274
+ continue'''
275
+
276
+ if OLD_CLOSE_PRS not in boss:
277
+ print("ERROR: close PRs block not found in boss")
278
+ sys.exit(1)
279
+ boss = boss.replace(OLD_CLOSE_PRS, NEW_CLOSE_PRS, 1)
280
+ print("Step 5 OK: _pr_check_loop detects HEAD changes and invalidates cache")
281
+
282
+ with open(MAIN, 'w', encoding='utf-8') as f:
283
+ f.write(boss)
284
+ print("Written OK")
285
+
286
+ try:
287
+ ast.parse(boss)
288
+ print("main.py Syntax OK")
289
+ except SyntaxError as e:
290
+ lines = boss.splitlines()
291
+ print(f"SyntaxError line {e.lineno}: {e.msg}")
292
+ for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
293
+ print(f" {i+1}: {lines[i]}")
294
+ sys.exit(1)