Jarvis2345 commited on
Commit
bdf3c32
·
verified ·
1 Parent(s): e3592be

deploy(S4): Blender headless pipeline + WebAR client + backend fixes (part 2)

Browse files
modules/initiative.py CHANGED
@@ -73,6 +73,12 @@ _lock = threading.RLock()
73
  # is still far more attentive than a person checking in on you.
74
  TICK_SECONDS = 900
75
 
 
 
 
 
 
 
76
  # Never act twice on the same judgement inside this window, even if the
77
  # situation persists. Without it a condition that stays true — a disk staying
78
  # full — would be acted on every single tick.
@@ -470,6 +476,46 @@ def judge(situation: str) -> Judgement:
470
  except Exception:
471
  pass
472
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  try:
474
  from core.brain_router import ask_router
475
  # NOT "what do you make of this?" — that is an invitation to comment,
@@ -478,7 +524,7 @@ def judge(situation: str) -> Judgement:
478
  rep = ask_router(
479
  user_text="Is this worth interrupting them for? Return the JSON.",
480
  system_prompt=system,
481
- context_block=f"SITUATION: {situation}",
482
  provider_order=["gemini", "local"],
483
  )
484
  raw = (getattr(rep, "text", "") or "").strip()
@@ -539,15 +585,25 @@ def tick(speak: Optional[Callable[[str], None]] = None,
539
 
540
  _mark_acted(verdict.topic)
541
 
542
- if verdict.say:
543
- # Through alert_manager so repeat suppression and snoozes apply to
544
- # unprompted speech exactly as they do to alerts.
545
- try:
546
- from modules.alert_manager import should_speak
547
- if should_speak(verdict.topic, verdict.urgency):
548
- say(verdict.say)
549
- except Exception:
550
- say(verdict.say)
 
 
 
 
 
 
 
 
 
 
551
 
552
  if allow_actions and verdict.action:
553
  try:
@@ -558,12 +614,43 @@ def tick(speak: Optional[Callable[[str], None]] = None,
558
  # It is announced first and deliberated, never silent.
559
  say(f"Acting on my own initiative: {verdict.action}.")
560
  res = execute(verdict.action, speak=say)
561
- # Written down whether or not anyone heard it happen. This is what
562
- # becomes "I did this for you, sir" when they come back.
563
- if res.ok:
 
564
  record_done(verdict.action, res.spoken, reversible=not risky)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  except Exception:
566
- pass
 
 
 
 
567
 
568
  return verdict
569
 
@@ -586,11 +673,32 @@ def start(speak: Optional[Callable[[str], None]] = None) -> bool:
586
  pass
587
 
588
  def _run():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  while _running:
590
  try:
591
  tick(_say)
592
  except Exception:
593
  pass
 
 
 
 
 
 
 
594
  time.sleep(TICK_SECONDS)
595
 
596
  _running = True
 
73
  # is still far more attentive than a person checking in on you.
74
  TICK_SECONDS = 900
75
 
76
+ # How often the runtime-repair queue is distilled into standing rules. Four
77
+ # ticks is once an hour: often enough that a lesson learned this morning is in
78
+ # force by the afternoon, rare enough that it costs a handful of model calls a
79
+ # day. See omega_executor.apply_pending_repairs.
80
+ REPAIR_EVERY_TICKS = 4
81
+
82
  # Never act twice on the same judgement inside this window, even if the
83
  # situation persists. Without it a condition that stays true — a disk staying
84
  # full — would be acted on every single tick.
 
476
  except Exception:
477
  pass
478
 
479
+ # WHO THIS IS HAPPENING TO.
480
+ #
481
+ # The context used to be `SITUATION: …` and nothing else — CPU, battery,
482
+ # window titles. So the one part of JARVIS that acts WITHOUT being asked was
483
+ # also the only part that knew nothing whatsoever about the operator: not
484
+ # what they had told him, not what they were working on, not what they had
485
+ # already asked him to leave alone. Deciding to act on someone unprompted
486
+ # while knowing nothing about them is the wrong way round — this is the
487
+ # place that needs the operator's context most, not least.
488
+ operator = ""
489
+ try:
490
+ from modules.memory import get_insights, get_unfinished_tasks
491
+ insights = get_insights() or []
492
+ tasks = get_unfinished_tasks() or []
493
+ bits = []
494
+ # The SHARED store first — this is where anything said on the phone
495
+ # lands, and the part of JARVIS that acts unasked is the last place that
496
+ # should be working from a different set of facts than the one the
497
+ # operator has been talking to all day.
498
+ try:
499
+ from modules.operator_memory import recall_text
500
+ shared = recall_text(limit=20)
501
+ if shared:
502
+ bits.append(shared)
503
+ except Exception:
504
+ pass
505
+ if insights:
506
+ bits.append("WHAT YOU KNOW ABOUT THEM: "
507
+ + " | ".join(str(i) for i in list(insights)[-12:]))
508
+ if tasks:
509
+ bits.append("THINGS THEY LEFT UNFINISHED: "
510
+ + ", ".join(str(t) for t in list(tasks)[:8]))
511
+ operator = "\n".join(bits)[:2000]
512
+ except Exception:
513
+ operator = ""
514
+
515
+ context_block = f"SITUATION: {situation}"
516
+ if operator:
517
+ context_block = f"{context_block}\n\n{operator}"
518
+
519
  try:
520
  from core.brain_router import ask_router
521
  # NOT "what do you make of this?" — that is an invitation to comment,
 
524
  rep = ask_router(
525
  user_text="Is this worth interrupting them for? Return the JSON.",
526
  system_prompt=system,
527
+ context_block=context_block,
528
  provider_order=["gemini", "local"],
529
  )
530
  raw = (getattr(rep, "text", "") or "").strip()
 
585
 
586
  _mark_acted(verdict.topic)
587
 
588
+ # ACT FIRST, THEN SAY WHAT HAPPENED.
589
+ #
590
+ # This block used to speak `verdict.say` BEFORE running `verdict.action`,
591
+ # and `verdict.say` is written by the model in the same breath as the plan —
592
+ # so it is always phrased as an accomplishment: "I've saved your unfinished
593
+ # work, sir." Then the action ran, and:
594
+ #
595
+ # · `if res.ok:` had no else, so a failed action said nothing at all;
596
+ # · `except Exception: pass` swallowed an executor that never even ran.
597
+ #
598
+ # Either way the operator had already been told it was done. Reported
599
+ # exactly that way: "he says I saved your unfinished work but does not
600
+ # actually do it, just says he did."
601
+ #
602
+ # Unprompted action is the one place this matters most. Nobody is watching
603
+ # when it happens, so the spoken line IS the whole record — if it lies,
604
+ # there is nothing else to catch it.
605
+ acted_ok: Optional[bool] = None
606
+ failure_note = ""
607
 
608
  if allow_actions and verdict.action:
609
  try:
 
614
  # It is announced first and deliberated, never silent.
615
  say(f"Acting on my own initiative: {verdict.action}.")
616
  res = execute(verdict.action, speak=say)
617
+ acted_ok = bool(getattr(res, "ok", False))
618
+ if acted_ok:
619
+ # Written down whether or not anyone heard it happen. This is
620
+ # what becomes "I did this for you, sir" when they come back.
621
  record_done(verdict.action, res.spoken, reversible=not risky)
622
+ else:
623
+ failure_note = (getattr(res, "spoken", "") or "").strip()
624
+ except Exception as exc:
625
+ acted_ok = False
626
+ failure_note = f"{type(exc).__name__}: {exc}"[:160]
627
+ import logging as _logging
628
+ _logging.getLogger(__name__).warning(
629
+ "initiative action failed: %s", failure_note)
630
+
631
+ if verdict.say:
632
+ line = verdict.say
633
+ if acted_ok is False:
634
+ # Replace the claim outright rather than appending a caveat to it —
635
+ # "I saved your work, but it failed" still opens by saying it saved.
636
+ line = "I tried to handle something for you and it did not work"
637
+ if verdict.action:
638
+ line += f": {verdict.action.strip().rstrip('.')}"
639
+ line += "."
640
+ if failure_note:
641
+ line += f" {failure_note}"
642
+ # Through alert_manager so repeat suppression and snoozes apply to
643
+ # unprompted speech exactly as they do to alerts.
644
+ try:
645
+ from modules.alert_manager import should_speak
646
+ if should_speak(verdict.topic, verdict.urgency):
647
+ say(line)
648
  except Exception:
649
+ say(line)
650
+ elif acted_ok is False:
651
+ # No line was planned, but something was attempted and failed. Silence
652
+ # here is how a broken autonomous action becomes invisible forever.
653
+ say("I tried to take care of something and it did not work.")
654
 
655
  return verdict
656
 
 
673
  pass
674
 
675
  def _run():
676
+ # THE LASTING FIX, ON ITS OWN SCHEDULE.
677
+ #
678
+ # omega_executor repairs a broken step mid-task so the operator is not
679
+ # blocked — that half worked. The other half, "then later fix it
680
+ # properly by yourself, and tell me once it is done", was a queue with
681
+ # no consumer: `pending_repairs()` was read by exactly one route that
682
+ # displays it. Every lesson was re-learned from scratch on every run.
683
+ #
684
+ # Here is the consumer. It runs far less often than the watch tick,
685
+ # because distilling a rule costs a model call and nothing is urgent
686
+ # about it, and it speaks only after the fact — which is what was asked
687
+ # for: do not interrupt me doing it, tell me when it is done.
688
+ every = max(1, int(REPAIR_EVERY_TICKS))
689
+ ticks = 0
690
  while _running:
691
  try:
692
  tick(_say)
693
  except Exception:
694
  pass
695
+ ticks += 1
696
+ if ticks % every == 0:
697
+ try:
698
+ from modules.omega_executor import apply_pending_repairs
699
+ apply_pending_repairs(speak=_say)
700
+ except Exception:
701
+ pass
702
  time.sleep(TICK_SECONDS)
703
 
704
  _running = True
modules/input_control.py CHANGED
@@ -12,6 +12,7 @@ from __future__ import annotations
12
 
13
  import time
14
  from dataclasses import dataclass
 
15
 
16
  import keyboard # type: ignore
17
 
@@ -28,16 +29,261 @@ class InputResult:
28
 
29
 
30
  def type_text(text: str) -> InputResult:
 
31
  t = (text or "")
32
  if not t.strip():
33
  return InputResult(False, "No text to type.")
34
  try:
35
  keyboard.write(t)
36
- return InputResult(True, "Typed.")
 
 
37
  except Exception as e:
38
  return InputResult(False, f"Type failed: {e}")
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def hotkey(combo: str) -> InputResult:
42
  c = (combo or "").strip()
43
  if not c:
 
12
 
13
  import time
14
  from dataclasses import dataclass
15
+ from typing import Optional
16
 
17
  import keyboard # type: ignore
18
 
 
29
 
30
 
31
  def type_text(text: str) -> InputResult:
32
+ """Type into WHATEVER has focus. Prefer [type_into] — see the note there."""
33
  t = (text or "")
34
  if not t.strip():
35
  return InputResult(False, "No text to type.")
36
  try:
37
  keyboard.write(t)
38
+ # Deliberately does NOT claim the text landed anywhere in particular:
39
+ # this function cannot know. type_into can, and says so.
40
+ return InputResult(True, f"Sent {len(t)} characters to the focused window.")
41
  except Exception as e:
42
  return InputResult(False, f"Type failed: {e}")
43
 
44
 
45
+ # ── Typing INTO something, with proof ────────────────────────────────────────
46
+ #
47
+ # "Open the terminal and write claude" opened the terminal and typed nothing,
48
+ # and JARVIS reported: "Done. 3 steps: Open the Windows terminal; waited 1s;
49
+ # Write claude to the terminal." Every layer said yes:
50
+ #
51
+ # · keyboard.write() cannot fail loudly — it posts keystrokes to whatever has
52
+ # focus at that instant. If the terminal has not finished opening, they go
53
+ # to the desktop and vanish.
54
+ # · type_text returned InputResult(True, "Typed.") unconditionally.
55
+ # · omega_executor._run_shell scores a step by `returncode == 0`, and a
56
+ # PowerShell SendKeys exits 0 whether or not anything received the keys.
57
+ #
58
+ # Exit code is evidence that a command RAN. For UI automation it is not evidence
59
+ # that the intended thing HAPPENED, and the two were being treated as the same.
60
+ #
61
+ # So this focuses the target window, CHECKS that it really is in the foreground,
62
+ # and only then types. If focus cannot be won — Windows refuses
63
+ # SetForegroundWindow to background processes in several situations — it returns
64
+ # False with the window that actually had focus, which is a fact the operator
65
+ # can act on.
66
+
67
+ def _win32():
68
+ import ctypes
69
+ from ctypes import wintypes
70
+ return ctypes, wintypes, ctypes.windll.user32
71
+
72
+
73
+ def foreground_title() -> str:
74
+ """Title of the window that currently has focus ('' if unavailable)."""
75
+ try:
76
+ ctypes, wintypes, u32 = _win32()
77
+ hwnd = u32.GetForegroundWindow()
78
+ if not hwnd:
79
+ return ""
80
+ n = u32.GetWindowTextLengthW(hwnd)
81
+ buf = ctypes.create_unicode_buffer(n + 1)
82
+ u32.GetWindowTextW(hwnd, buf, n + 1)
83
+ return buf.value or ""
84
+ except Exception:
85
+ return ""
86
+
87
+
88
+ def focus_window(title_substr: str, timeout: float = 6.0) -> InputResult:
89
+ """Bring a window matching `title_substr` to the front, and VERIFY it."""
90
+ want = (title_substr or "").strip().lower()
91
+ if not want:
92
+ return InputResult(False, "No window named.")
93
+ try:
94
+ ctypes, wintypes, u32 = _win32()
95
+ except Exception as e:
96
+ return InputResult(False, f"Win32 unavailable: {e}")
97
+
98
+ SW_RESTORE = 9
99
+ deadline = time.time() + max(0.5, timeout)
100
+
101
+ while time.time() < deadline:
102
+ found: list[int] = []
103
+
104
+ @ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
105
+ def _enum(hwnd, _lparam):
106
+ if not u32.IsWindowVisible(hwnd):
107
+ return True
108
+ n = u32.GetWindowTextLengthW(hwnd)
109
+ if n <= 0:
110
+ return True
111
+ buf = ctypes.create_unicode_buffer(n + 1)
112
+ u32.GetWindowTextW(hwnd, buf, n + 1)
113
+ if want in (buf.value or "").lower():
114
+ found.append(hwnd)
115
+ return False
116
+ return True
117
+
118
+ try:
119
+ u32.EnumWindows(_enum, 0)
120
+ except Exception:
121
+ pass
122
+
123
+ if found:
124
+ hwnd = found[0]
125
+ try:
126
+ u32.ShowWindow(hwnd, SW_RESTORE)
127
+ # SetForegroundWindow ALONE IS NOT ENOUGH.
128
+ #
129
+ # Windows refuses it from a process that does not already own
130
+ # the foreground — it flashes the taskbar button instead and
131
+ # returns as if nothing were wrong. That is the single biggest
132
+ # reason "open X and type Y" typed into the wrong place: the
133
+ # focus call quietly did nothing and the keystrokes went to
134
+ # whatever was still in front.
135
+ #
136
+ # Attaching our input queue to the foreground window's thread
137
+ # makes us a legitimate foreground-setter for the duration.
138
+ # This is the documented way round the restriction and is what
139
+ # every automation tool does.
140
+ cur = u32.GetForegroundWindow()
141
+ our_tid = ctypes.windll.kernel32.GetCurrentThreadId()
142
+ tgt_tid = u32.GetWindowThreadProcessId(hwnd, None)
143
+ cur_tid = u32.GetWindowThreadProcessId(cur, None) if cur else 0
144
+ attached = []
145
+ for tid in {tgt_tid, cur_tid}:
146
+ if tid and tid != our_tid and u32.AttachThreadInput(our_tid, tid, True):
147
+ attached.append(tid)
148
+ try:
149
+ u32.BringWindowToTop(hwnd)
150
+ u32.SetForegroundWindow(hwnd)
151
+ u32.SetActiveWindow(hwnd)
152
+ u32.SetFocus(hwnd)
153
+ finally:
154
+ for tid in attached:
155
+ u32.AttachThreadInput(our_tid, tid, False)
156
+ except Exception:
157
+ pass
158
+ # VERIFY. SetForegroundWindow returns non-zero on refusal in some
159
+ # cases and Windows may simply flash the taskbar instead, so the
160
+ # only trustworthy check is asking who is in front now.
161
+ time.sleep(0.25)
162
+ if u32.GetForegroundWindow() == hwnd:
163
+ return InputResult(True, f"Focused '{title_substr}'.")
164
+ time.sleep(0.3)
165
+
166
+ actual = foreground_title()
167
+ return InputResult(
168
+ False,
169
+ f"Could not bring '{title_substr}' to the front"
170
+ + (f"; '{actual}' has focus instead." if actual else "."),
171
+ )
172
+
173
+
174
+ def read_focused_text() -> Optional[str]:
175
+ """Read the text of the focused window's edit control, or None.
176
+
177
+ Generic Win32: any standard EDIT / RichEdit / Scintilla child answers
178
+ WM_GETTEXT. Terminals, Electron apps and custom-drawn UIs do not, and that
179
+ is why this returns None rather than "" — "could not read" and "read, and it
180
+ was empty" are completely different answers and must not be confused.
181
+ """
182
+ try:
183
+ import ctypes
184
+ ctypes, wintypes, u32 = _win32()
185
+ hwnd = u32.GetForegroundWindow()
186
+ if not hwnd:
187
+ return None
188
+ found: list[str] = []
189
+
190
+ @ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
191
+ def _child(h, _l):
192
+ cls = ctypes.create_unicode_buffer(256)
193
+ u32.GetClassNameW(h, cls, 256)
194
+ name = (cls.value or "").lower()
195
+ if "edit" in name or "scintilla" in name:
196
+ n = u32.SendMessageW(h, 0x000E, 0, 0) # WM_GETTEXTLENGTH
197
+ b = ctypes.create_unicode_buffer(n + 2)
198
+ u32.SendMessageW(h, 0x000D, n + 2, b) # WM_GETTEXT
199
+ found.append(b.value or "")
200
+ return False
201
+ return True
202
+
203
+ u32.EnumChildWindows(hwnd, _child, 0)
204
+ return found[0] if found else None
205
+ except Exception:
206
+ return None
207
+
208
+
209
+ def type_into(text: str, window: str = "", timeout: float = 6.0,
210
+ delay: float = 0.012) -> InputResult:
211
+ """Focus `window`, prove it has focus, type `text`, then CHECK IT LANDED.
212
+
213
+ Three failures this has to survive, all found by testing it rather than by
214
+ reading it:
215
+
216
+ 1. WRONG WINDOW. Without an explicit focus step the keys go wherever focus
217
+ happens to be, which right after launching an app is a race against that
218
+ app's startup.
219
+
220
+ 2. DROPPED CHARACTERS. `keyboard.write(t)` with no delay sends keystrokes
221
+ faster than the target can consume them. Measured on Notepad immediately
222
+ after a focus change: of `omega_verify_1786893995`, exactly ONE character
223
+ arrived — and the step still reported "Done". A per-character delay is
224
+ not politeness, it is the difference between typing and appearing to.
225
+
226
+ 3. CLAIMING WITHOUT LOOKING. Focus being correct before and after does not
227
+ mean the text is in the control. Where the control can be read, it is
228
+ read back and compared; where it cannot, the result says so explicitly
229
+ instead of asserting success it has not checked.
230
+ """
231
+ t = text or ""
232
+ if not t.strip():
233
+ return InputResult(False, "No text to type.")
234
+
235
+ if window.strip():
236
+ got = focus_window(window, timeout=timeout)
237
+ if not got.ok:
238
+ return InputResult(False, f"Did not type: {got.message}")
239
+
240
+ before_text = read_focused_text()
241
+ before = foreground_title()
242
+ try:
243
+ # delay= is the fix for (2). keyboard.write accepts it on every version
244
+ # that has write(); the fallback keeps older builds working.
245
+ try:
246
+ keyboard.write(t, delay=delay)
247
+ except TypeError:
248
+ for ch in t:
249
+ keyboard.write(ch)
250
+ time.sleep(delay)
251
+ except Exception as e:
252
+ return InputResult(False, f"Type failed: {e}")
253
+
254
+ after = foreground_title()
255
+ if before and after and before != after:
256
+ return InputResult(
257
+ False,
258
+ f"Focus moved from '{before}' to '{after}' while typing — "
259
+ f"the text may be incomplete.",
260
+ )
261
+
262
+ # ── did it land? ────────────────────────────────────────────────────────
263
+ time.sleep(0.15) # let the control settle
264
+ after_text = read_focused_text()
265
+ if after_text is None:
266
+ # Unreadable control (a terminal, a browser, a custom UI). Say so.
267
+ return InputResult(
268
+ True,
269
+ f"Typed {len(t)} characters into '{after or window}' "
270
+ f"(this window's contents cannot be read back, so delivery is "
271
+ f"not confirmed).",
272
+ )
273
+
274
+ if t in after_text:
275
+ return InputResult(
276
+ True, f"Typed and verified {len(t)} characters in '{after or window}'.")
277
+
278
+ # Landed partially, or not at all. Report exactly how much.
279
+ grew = len(after_text) - len(before_text or "")
280
+ return InputResult(
281
+ False,
282
+ f"Only {max(0, grew)} of {len(t)} characters reached "
283
+ f"'{after or window}' — the text was not fully entered.",
284
+ )
285
+
286
+
287
  def hotkey(combo: str) -> InputResult:
288
  c = (combo or "").strip()
289
  if not c:
modules/intent.py CHANGED
@@ -54,7 +54,7 @@ class Intent:
54
  # Deliberately generous. These are not "the supported phrasings" — they are the
55
  # ones worth answering without a round trip.
56
 
57
- _NAME = r"(?:jarvis|jarv|friday|fri)"
58
 
59
  # Any greeting-ish token, or none at all, in front of the name. This is what
60
  # makes "yo jarvis" work without anybody having listed "yo".
 
54
  # Deliberately generous. These are not "the supported phrasings" — they are the
55
  # ones worth answering without a round trip.
56
 
57
+ _NAME = r"(?:jarvis|jarvid|jarv|jervis|jarvas|jarwis|javis|travis|j|friday|fri|fryday|freeday|frydey)"
58
 
59
  # Any greeting-ish token, or none at all, in front of the name. This is what
60
  # makes "yo jarvis" work without anybody having listed "yo".
modules/omega_executor.py CHANGED
@@ -91,6 +91,22 @@ _DESTRUCTIVE_PATTERNS = (
91
  r"\bclear-content\b", r"\brm\s+-[a-z]*[rf]", r"\brm\s+",
92
  r"\bcipher\s+/w\b", r"\bbcdedit\b", r"\breg\s+delete\b",
93
  r"\bremove-partition\b", r"\bclear-disk\b",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  )
95
 
96
 
@@ -201,11 +217,103 @@ Step kinds:
201
  {"kind":"say","text":"..."}
202
  Speak progress mid-chain.
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  Rules:
205
  - Decompose chains fully and in order. "download X, install it, run it 10
206
  minutes, close everything, shut down" is at least 5 steps, not 1.
207
  - Irreversible steps (shutdown, restart, format, mass delete) go LAST.
208
- - Never invent a tool name that is not in the catalog.
 
 
209
  - Never ask the user a question; choose the sensible interpretation and act.
210
  - Prefer one capable step over many timid ones, but never merge a wait.
211
  """
@@ -254,7 +362,12 @@ def _extract_json(raw: str) -> Optional[dict]:
254
 
255
  def plan_request(request: str, context: str = "") -> dict:
256
  """Turn plain speech into a step plan. Never raises."""
257
- system = _PLANNER_RULES + "\n\nCATALOG:\n" + _catalog_text()
 
 
 
 
 
258
  ctx = [f"Machine: Windows. Working dir: {os.getcwd()}"]
259
  if context:
260
  ctx.append(context)
@@ -374,7 +487,25 @@ def _run_tool(name: str, args: dict) -> tuple[bool, str, str]:
374
  if "e" in box:
375
  return False, "", str(box["e"])
376
  res = box.get("v")
377
- return True, ("" if res is None else str(res))[:4000], ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  except Exception as e:
379
  return False, "", str(e)
380
 
@@ -406,7 +537,17 @@ def execute(request: str,
406
  prev_output = ""
407
  ok_all = True
408
 
409
- for i, step in enumerate(steps, 1):
 
 
 
 
 
 
 
 
 
 
410
  kind = str(step.get("kind", "")).lower()
411
  why = str(step.get("why") or step.get("text") or kind)[:180]
412
 
@@ -528,16 +669,96 @@ def execute(request: str,
528
  break
529
  step = fixed # each attempt learns from the last error
530
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  results.append(StepResult(i, kind, why, good, out, err))
532
  if out:
533
  prev_output = out
534
  if not good:
535
- ok_all = False
536
  _log(f"step {i} failed after repair attempts: {err[:200]}")
537
- # A broken link means the rest of the chain is operating on false
538
- # premises, so stop rather than plough on.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  break
540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  if ok_all:
542
  spoken = _summarise_success(request, results)
543
  else:
@@ -550,6 +771,48 @@ def execute(request: str,
550
 
551
  # ── Self-repair ─────────────────────────────────────────────────────────────
552
  MAX_REPAIRS = 2 # two corrected attempts, then admit it is stuck
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  _REPAIR_LOG = os.path.join(
554
  os.environ.get("JARVIS_APP_DATA_DIR") or os.path.join(
555
  os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".friday_data"),
@@ -653,6 +916,172 @@ def pending_repairs() -> list[dict]:
653
  return []
654
 
655
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  def _summarise_success(request: str, results: list[StepResult]) -> str:
657
  done = [r for r in results if r.ok and r.kind not in ("say",)]
658
  if not done:
 
91
  r"\bclear-content\b", r"\brm\s+-[a-z]*[rf]", r"\brm\s+",
92
  r"\bcipher\s+/w\b", r"\bbcdedit\b", r"\breg\s+delete\b",
93
  r"\bremove-partition\b", r"\bclear-disk\b",
94
+
95
+ # PUBLISHING IS IRREVERSIBLE TOO, and it is newly reachable.
96
+ #
97
+ # The planner now knows how to deploy the Space, push to git and sync the
98
+ # WebAR bundle into the APK and cloud copies. Those are not "deletions", so
99
+ # none of the patterns above matched them — yet a deploy puts code on the
100
+ # public internet under the operator's name, and a push cannot be recalled
101
+ # once anyone has fetched it. Something that leaves this machine and cannot
102
+ # be pulled back deserves the same pause as something that wipes a disk.
103
+ #
104
+ # This does not block them. It routes them through the same deliberation and
105
+ # cancel window as a shutdown, so an unattended run says what it is about to
106
+ # publish before it publishes it.
107
+ r"\bdeploy_hf_space\b", r"\bupload_folder\b", r"\bhuggingface_hub\b",
108
+ r"\bgit\s+push\b", r"\bgit\s+commit\b",
109
+ r"\bsync_webar_bundle\b",
110
  )
111
 
112
 
 
217
  {"kind":"say","text":"..."}
218
  Speak progress mid-chain.
219
 
220
+ ANY step may also carry:
221
+
222
+ "verify": {"kind":"shell","cmd":"<check>"} (or {"kind":"python","code":"..."})
223
+ A check that must SUCCEED, or the step is reported as failed.
224
+
225
+ REQUIRED on the step that produces the thing being asked for. If the
226
+ request results in a file, a setting, an installed program, a download or
227
+ a message sent, the step that does it MUST carry a verify. A run that
228
+ claims success without one has proved nothing — and this was not
229
+ hypothetical: "write a line to <path>" was planned as a single shell step
230
+ with no check, the command exited 0, no file was created, and it was
231
+ reported as "Done."
232
+
233
+ Use it whenever the step's real effect happens OUTSIDE the process it
234
+ runs — a keystroke, a click, a window, a file, a setting, a download.
235
+ An exit code proves the command RAN; it does not prove the thing HAPPENED.
236
+ A PowerShell SendKeys exits 0 whether or not any window received the keys,
237
+ which is exactly how "open the terminal and write claude" once opened the
238
+ terminal, typed nothing, and was reported as three completed steps.
239
+
240
+ Verify the RESULT, not the command:
241
+ wrote a file -> Test-Path, and check its length
242
+ opened an app -> Get-Process, or the window title
243
+ downloaded a file -> the file exists and is not zero bytes
244
+ changed a setting -> read the setting back
245
+
246
+ THE WHOLE SYSTEM IS REACHABLE — not just this PC.
247
+
248
+ A `python` step can do anything this machine can do, and this machine can
249
+ change every surface OMEGA runs on. What was missing was never the power; it
250
+ was knowing WHERE things live, so requests about the phone, the cloud or the
251
+ AR client were treated as impossible when they were merely unlisted.
252
+
253
+ Paths are relative to the repo root (the working dir given below).
254
+
255
+ BACKEND (this PC and the cloud run the SAME code)
256
+ backend/ , modules/ , core/ , config.py
257
+ Restart to apply: python -m backend.main (health: GET :7474/health)
258
+
259
+ HF SPACE — Jarvis2345/jarvis-cloud
260
+ Editing backend/ is NOT enough; the Space serves its own copy.
261
+ Deploy: python scripts/deploy_hf_space.py (--dry-run first)
262
+ Needs HF_TOKEN. Verify: GET https://jarvis2345-jarvis-cloud.hf.space/health
263
+ This publishes to the internet. Treat it as irreversible: say so first.
264
+
265
+ WEBAR — THREE physical copies of one bundle, and nothing keeps them in sync
266
+ source of truth phone/eightwall_ar/dist
267
+ cloud copy cloud_deployment/webar (served by the Space)
268
+ apk copy phone/jarvis-mobile-guardian/app/src/main/assets/webar
269
+ NEVER hand-copy — that is what caused the drift, twice.
270
+ Sync: python scripts/sync_webar_bundle.py (--check proves they agree)
271
+ A WebAR change that skips this reaches nobody.
272
+
273
+ ANDROID APP (Guardian)
274
+ phone/jarvis-mobile-guardian/
275
+ Build: gradlew.bat :app:assembleRelease
276
+ Needs KEYSTORE_PATH / STORE_PASSWORD / KEY_PASSWORD / KEY_ALIAS or the APK
277
+ ships UNSIGNED and silently uninstallable. Install: adb install -r <apk>.
278
+
279
+ DESKTOP APP (exe) src/ , dist/
280
+ PLAN / TIER GATING scripts/tier_partition.py
281
+
282
+ When a request touches one of these, do the edit AND the step that makes it
283
+ real — a source change with no sync, deploy, build or restart has changed
284
+ nothing the operator can see, and reporting it as done is the same lie as any
285
+ other unverified step. Verify against the LIVE surface: /health, --check, the
286
+ installed versionName.
287
+
288
+ SAVING is a FLOW, not a keystroke.
289
+ Ctrl+S on an unsaved document opens a Save-As dialog, which is a SEPARATE
290
+ WINDOW with its own filename field and its own confirm button. Sending Ctrl+S
291
+ and stopping there leaves that dialog open and nothing saved, while every
292
+ command involved exits 0.
293
+
294
+ Prefer writing the file directly when you can — a `python` step that writes
295
+ the content to a path is one step, needs no window, and can be verified with
296
+ Test-Path. Drive the GUI only when the content exists solely inside the app.
297
+
298
+ When you must use the dialog, treat each part as its own step:
299
+ Ctrl+S -> wait -> type_into the dialog (window "Save As")
300
+ -> Enter -> verify with Test-Path on the expected path
301
+ and ALWAYS end with a `verify` that the file now exists and is non-empty.
302
+ "It said Done" is not the same as "the file is on disk".
303
+
304
+ TYPING AND FOCUS — do not use SendKeys.
305
+ Use {"kind":"tool","name":"type_into","args":{"text":"...","window":"..."}}.
306
+ It takes focus deliberately, PROVES the window is in front before sending a
307
+ key, and fails loudly when it cannot. Always pass `window` after launching
308
+ something: keystrokes sent while an app is still starting go to the desktop.
309
+
310
  Rules:
311
  - Decompose chains fully and in order. "download X, install it, run it 10
312
  minutes, close everything, shut down" is at least 5 steps, not 1.
313
  - Irreversible steps (shutdown, restart, format, mass delete) go LAST.
314
+ - Never invent a tool name that is not in the catalog — but you are NOT limited
315
+ to the catalog: `python` lets you write whatever the request actually needs,
316
+ and that is the intended path for anything unanticipated.
317
  - Never ask the user a question; choose the sensible interpretation and act.
318
  - Prefer one capable step over many timid ones, but never merge a wait.
319
  """
 
362
 
363
  def plan_request(request: str, context: str = "") -> dict:
364
  """Turn plain speech into a step plan. Never raises."""
365
+ # Standing rules distilled from things that went wrong before on THIS
366
+ # machine. Without this the lasting-fix queue is just a diary: the same
367
+ # command is planned, fails, is repaired at runtime, and is planned exactly
368
+ # the same way again the next time it is asked for.
369
+ system = (_PLANNER_RULES + "\n\nCATALOG:\n" + _catalog_text()
370
+ + _learned_fixes_text())
371
  ctx = [f"Machine: Windows. Working dir: {os.getcwd()}"]
372
  if context:
373
  ctx.append(context)
 
487
  if "e" in box:
488
  return False, "", str(box["e"])
489
  res = box.get("v")
490
+
491
+ text = ("" if res is None else str(res))[:4000]
492
+
493
+ # A TOOL THAT SAYS IT FAILED, FAILED.
494
+ #
495
+ # This returned `True` for any string a tool produced, so a tool whose
496
+ # own answer was "Failed: Could not bring 'Notepad' to the front" was
497
+ # recorded as a completed step and summarised as "Done". The tool's
498
+ # verdict was computed and then discarded — the same shape as scoring a
499
+ # shell step by its exit code, one layer up.
500
+ #
501
+ # Only the START of the answer is examined, deliberately: a web search
502
+ # or a log excerpt may legitimately contain the word "failed" in its
503
+ # body, and failing the step for that would be its own kind of wrong.
504
+ head = text.lstrip()[:80].lower()
505
+ if head.startswith(("failed", "error:", "error ", "could not",
506
+ "unable to", "cannot ", "no such tool")):
507
+ return False, "", text
508
+ return True, text, ""
509
  except Exception as e:
510
  return False, "", str(e)
511
 
 
537
  prev_output = ""
538
  ok_all = True
539
 
540
+ # Index-based rather than `for … in enumerate(steps)` so the REMAINING work
541
+ # can be replaced mid-run. See the re-plan block at the bottom of the loop:
542
+ # a long chain used to die at its first unrecoverable step, which is exactly
543
+ # where a person would instead try a different route.
544
+ replans = 0
545
+ idx = 0
546
+
547
+ while idx < len(steps):
548
+ step = steps[idx]
549
+ idx += 1
550
+ i = idx # 1-based step number, as before
551
  kind = str(step.get("kind", "")).lower()
552
  why = str(step.get("why") or step.get("text") or kind)[:180]
553
 
 
669
  break
670
  step = fixed # each attempt learns from the last error
671
 
672
+ # ── DID IT ACTUALLY HAPPEN? ──────────────────────────────────────────
673
+ #
674
+ # `good` so far means the command RAN. For anything whose effect is
675
+ # outside the process — a keystroke, a click, a window, a file, a
676
+ # setting — that is not the same as the effect having happened, and the
677
+ # two were being treated as identical:
678
+ #
679
+ # return proc.returncode == 0, out, err # _run_shell
680
+ #
681
+ # A PowerShell SendKeys exits 0 whether or not any window received the
682
+ # keys. That is why "open the terminal and write claude" opened the
683
+ # terminal, typed nothing, and was reported as "Done. 3 steps".
684
+ #
685
+ # So a step may carry `verify`: a shell or python snippet that must
686
+ # SUCCEED for the step to count. This deliberately does not restrict
687
+ # what a step is allowed to do — shell, python and code written on the
688
+ # spot all still work, and can be anything. It restricts what may be
689
+ # CLAIMED about them. Steps with no `verify` behave exactly as before.
690
+ verify = step.get("verify") if isinstance(step, dict) else None
691
+ if good and verify:
692
+ vkind = str((verify or {}).get("kind") or "shell").lower()
693
+ if vkind == "python":
694
+ vgood, vout, verr = _run_python(str(verify.get("code") or ""), out)
695
+ else:
696
+ vgood, vout, verr = _run_shell(str(verify.get("cmd") or ""))
697
+ if not vgood:
698
+ good = False
699
+ err = (err + "\n" if err else "") + (
700
+ f"verification failed: {verr or vout or 'check did not pass'}")
701
+ why = f"{why} (unverified)"
702
+ _log(f"step {i} ran but FAILED VERIFICATION: {verr or vout}")
703
+
704
  results.append(StepResult(i, kind, why, good, out, err))
705
  if out:
706
  prev_output = out
707
  if not good:
 
708
  _log(f"step {i} failed after repair attempts: {err[:200]}")
709
+
710
+ # TRY ANOTHER WAY BEFORE GIVING UP.
711
+ #
712
+ # This used to `break` — the rest of a long chain was abandoned the
713
+ # moment one link broke. `_repair_step` had already tried to fix
714
+ # THAT COMMAND twice, so by here the approach itself is what is
715
+ # wrong, and rewriting the same command a third time will not help.
716
+ #
717
+ # What is missing is what a person does next: keep everything that
718
+ # already worked, and find a different route for what is left.
719
+ # "Save it" is the case that exposed this — Ctrl+S, a Save-As
720
+ # dialog, a filename field and a confirm button is a flow with
721
+ # several shapes depending on the app, and the first shape being
722
+ # wrong should not end the task.
723
+ #
724
+ # Bounded to MAX_REPLANS so a plan that cannot work fails in seconds
725
+ # rather than looping, and it is told exactly what already succeeded
726
+ # so it does not redo it.
727
+ if replans < MAX_REPLANS:
728
+ new_steps = _replan_remainder(
729
+ request, results, step, err, steps[idx:])
730
+ if new_steps:
731
+ replans += 1
732
+ steps = steps[:idx] + new_steps
733
+ _log(f"re-planned {len(new_steps)} step(s) after step {i} failed")
734
+ say("That didn't work. Trying another way.")
735
+ continue
736
+
737
+ ok_all = False
738
  break
739
 
740
+ # ── FINAL OUTCOME GATE ──────────────────────────────────────────────────
741
+ #
742
+ # `verify` on a step is optional, and the planner routinely omits it. Caught
743
+ # exactly that way: "write a line to <path>" produced one shell step that
744
+ # failed, was repaired, exited 0, created nothing, and was reported as
745
+ # "Done." — because no step carried a check and exit codes were all the
746
+ # engine had to go on.
747
+ #
748
+ # So the REQUEST itself is checked, not just the steps. If the operator
749
+ # named a concrete file and that file is not there afterwards, the run did
750
+ # not do what was asked, whatever the exit codes said. Deterministic, no
751
+ # model call, and it cannot be forgotten by a planner.
752
+ if ok_all:
753
+ missing = _unmet_artifacts(request)
754
+ if missing:
755
+ ok_all = False
756
+ _log(f"outcome gate: promised artifacts missing: {missing}")
757
+ results.append(StepResult(
758
+ len(results) + 1, "verify",
759
+ "the file the request asked for was not created", False,
760
+ error="missing after the run: " + ", ".join(missing)))
761
+
762
  if ok_all:
763
  spoken = _summarise_success(request, results)
764
  else:
 
771
 
772
  # ── Self-repair ─────────────────────────────────────────────────────────────
773
  MAX_REPAIRS = 2 # two corrected attempts, then admit it is stuck
774
+ MAX_REPLANS = 2 # …then two attempts at a DIFFERENT route for the rest
775
+
776
+
777
+ def _replan_remainder(request: str,
778
+ done: list["StepResult"],
779
+ failed_step: dict,
780
+ error: str,
781
+ remaining: list[dict]) -> Optional[list[dict]]:
782
+ """Find another way to finish, keeping everything that already worked.
783
+
784
+ Distinct from `_repair_step`, which rewrites ONE command that errored. By
785
+ the time this runs that has already been tried twice, so the command is not
786
+ the problem — the approach is. This asks for a new plan for the unfinished
787
+ part only, and is told what has already been done so it does not repeat it.
788
+ """
789
+ try:
790
+ from core.brain_router import ask_router
791
+ did = "\n".join(f"- DONE: {r.summary}" for r in done if r.ok) or "- (nothing yet)"
792
+ left = json.dumps(remaining)[:1200] if remaining else "(nothing)"
793
+ ctx = (f"ORIGINAL REQUEST: {request}\n"
794
+ f"ALREADY COMPLETED — do NOT redo these:\n{did}\n"
795
+ f"THE STEP THAT FAILED TWICE: {json.dumps(failed_step)[:800]}\n"
796
+ f"ERROR: {error[:800]}\n"
797
+ f"WHAT WAS STILL PLANNED: {left}\n"
798
+ f"Machine: Windows. Working dir: {os.getcwd()}")
799
+ rep = ask_router(
800
+ user_text=("Finish the request a DIFFERENT way. Return the same "
801
+ "JSON plan format, containing ONLY the remaining steps."),
802
+ system_prompt=_PLANNER_RULES + "\n\nCATALOG:\n" + _catalog_text()
803
+ + _learned_fixes_text(),
804
+ context_block=ctx,
805
+ provider_order=["gemini", "local"],
806
+ )
807
+ plan = _extract_json(getattr(rep, "text", "") or "")
808
+ if not isinstance(plan, dict):
809
+ return None
810
+ steps = [s for s in (plan.get("steps") or [])
811
+ if isinstance(s, dict) and s.get("kind")]
812
+ return steps[:MAX_STEPS] or None
813
+ except Exception as e:
814
+ _log(f"re-plan unavailable: {e}")
815
+ return None
816
  _REPAIR_LOG = os.path.join(
817
  os.environ.get("JARVIS_APP_DATA_DIR") or os.path.join(
818
  os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".friday_data"),
 
916
  return []
917
 
918
 
919
+ # ── The lasting fix ─────────────────────────────────────────────────────────
920
+ #
921
+ # _remember_repair's docstring promised this: "a command that had to be
922
+ # corrected at runtime will need correcting again next time unless something
923
+ # changes — so the evidence is kept". The evidence was kept. Nothing ever read
924
+ # it. `pending_repairs()` had exactly one caller in the whole codebase —
925
+ # backend/routes/engine_routes.py, which LISTS the queue for a UI — so every
926
+ # lesson learned mid-task was learned again from scratch on the next run, for
927
+ # ever.
928
+ #
929
+ # That is the half of the design the operator described as "then later fix
930
+ # properly by own without telling me, then after fix tell me", and it did not
931
+ # exist.
932
+ #
933
+ # What "properly" means here is deliberate. It does NOT mean editing source
934
+ # unsupervised — an autonomous process rewriting its own program while nobody
935
+ # is watching is a much larger risk than the bug it is fixing. It means the
936
+ # same mistake stops being made: each runtime correction becomes a standing
937
+ # rule that goes into the planner's prompt, so the broken form is never planned
938
+ # again on this machine.
939
+
940
+ _LEARNED_FIXES = os.path.join(
941
+ os.path.dirname(_REPAIR_LOG), "learned_fixes.json")
942
+
943
+
944
+ def _rule_key(rule: str) -> str:
945
+ """Normalise a rule so trivially different wordings collapse together."""
946
+ return re.sub(r"[^a-z0-9 ]+", "", (rule or "").lower()).strip()[:120]
947
+
948
+
949
+ def learned_fixes() -> list[dict]:
950
+ """Standing corrections distilled from past runtime repairs."""
951
+ try:
952
+ with open(_LEARNED_FIXES, "r", encoding="utf-8") as fh:
953
+ data = json.load(fh)
954
+ return data if isinstance(data, list) else []
955
+ except Exception:
956
+ return []
957
+
958
+
959
+ def _learned_fixes_text() -> str:
960
+ """The standing rules, as prompt text. Empty when there are none."""
961
+ rules = learned_fixes()
962
+ if not rules:
963
+ return ""
964
+ lines = [f"- {r.get('rule')}" for r in rules[-25:] if r.get("rule")]
965
+ if not lines:
966
+ return ""
967
+ return ("\nLEARNED ON THIS MACHINE — these went wrong before and were "
968
+ "corrected at runtime. Do not repeat them:\n" + "\n".join(lines))
969
+
970
+
971
+ def apply_pending_repairs(speak: Optional[Callable[[str], None]] = None,
972
+ max_items: int = 12) -> list[str]:
973
+ """Turn runtime corrections into standing rules. Returns what was learned.
974
+
975
+ Runs unattended. Speaks only AFTERWARDS, and only if something was actually
976
+ learned — the operator asked not to be interrupted during the fixing, but to
977
+ be told once it was done.
978
+ """
979
+ queue = pending_repairs()
980
+ if not queue:
981
+ return []
982
+
983
+ learned: list[dict] = list(learned_fixes())
984
+ known = {r.get("signature") for r in learned}
985
+ # Also dedupe on the RULE ITSELF. The signature is the exact broken command,
986
+ # so two runs that fail the same way with a different filename produce two
987
+ # different signatures and one identical lesson — observed immediately:
988
+ # "Use Set-Content instead of misspelled variants" was learned twice. The
989
+ # prompt these go into is finite; the same sentence twice costs room and
990
+ # teaches nothing extra.
991
+ known_rules = {_rule_key(r.get("rule", "")) for r in learned}
992
+ new_rules: list[str] = []
993
+
994
+ for item in queue[-max_items:]:
995
+ broken = item.get("broken") or {}
996
+ fixed = item.get("fixed") or {}
997
+ sig = f"{broken.get('cmd') or broken.get('code') or broken.get('name')}"[:300]
998
+ if not sig or sig in known:
999
+ continue
1000
+ try:
1001
+ from core.brain_router import ask_router
1002
+ rep = ask_router(
1003
+ user_text=("Write ONE short standing rule, in a single "
1004
+ "sentence, that would stop this from going wrong "
1005
+ "again. State the correct form. No preamble."),
1006
+ system_prompt=("You turn a one-off runtime correction into a "
1007
+ "durable rule for a command planner. One "
1008
+ "sentence. Imperative. Name the correct command "
1009
+ "or approach explicitly."),
1010
+ context_block=(f"REQUEST: {item.get('request','')}\n"
1011
+ f"WHAT FAILED: {json.dumps(broken)[:600]}\n"
1012
+ f"ERROR: {item.get('error','')[:400]}\n"
1013
+ f"WHAT WORKED: {json.dumps(fixed)[:600]}"),
1014
+ provider_order=["gemini", "local"],
1015
+ )
1016
+ rule = (getattr(rep, "text", "") or "").strip().strip('"')[:300]
1017
+ except Exception as exc:
1018
+ _log(f"lasting fix unavailable: {exc}")
1019
+ break
1020
+ if not rule:
1021
+ continue
1022
+ rkey = _rule_key(rule)
1023
+ if rkey in known_rules:
1024
+ # Same lesson, different filename. Remember the signature so it is
1025
+ # not re-derived, but do not add the sentence twice.
1026
+ known.add(sig)
1027
+ continue
1028
+ learned.append({"at": time.time(), "signature": sig, "rule": rule,
1029
+ "request": str(item.get("request", ""))[:200]})
1030
+ known.add(sig)
1031
+ known_rules.add(rkey)
1032
+ new_rules.append(rule)
1033
+
1034
+ if not new_rules:
1035
+ return []
1036
+
1037
+ try:
1038
+ os.makedirs(os.path.dirname(_LEARNED_FIXES), exist_ok=True)
1039
+ with open(_LEARNED_FIXES, "w", encoding="utf-8") as fh:
1040
+ json.dump(learned[-200:], fh, indent=2)
1041
+ # The queue has been distilled; clearing it stops the same entries being
1042
+ # re-learned on every pass.
1043
+ with open(_REPAIR_LOG, "w", encoding="utf-8") as fh:
1044
+ json.dump([], fh)
1045
+ except Exception as exc:
1046
+ _log(f"could not persist learned fixes: {exc}")
1047
+ return []
1048
+
1049
+ if speak:
1050
+ n = len(new_rules)
1051
+ speak(f"I went back and fixed {n} thing{'s' if n != 1 else ''} "
1052
+ f"that had been going wrong: {new_rules[0]}")
1053
+ _log(f"learned {len(new_rules)} lasting fix(es)")
1054
+ return new_rules
1055
+
1056
+
1057
+ def _unmet_artifacts(request: str) -> list[str]:
1058
+ """File paths the request clearly asked for that do not exist afterwards.
1059
+
1060
+ Only paths written out explicitly and unambiguously — an absolute path with
1061
+ an extension. A request that merely MENTIONS a file it wants read is not
1062
+ caught here, and should not be: this exists to catch "make me X" that
1063
+ produced no X, not to guess at intent.
1064
+ """
1065
+ try:
1066
+ # C:\dir\name.ext or /dir/name.ext — and the path must START at a
1067
+ # boundary. Without the lookbehind, "read modules/omega_executor.py"
1068
+ # matched "/omega_executor.py" out of the middle of a RELATIVE path and
1069
+ # then reported a perfectly present file as missing.
1070
+ pattern = (r"""(?:(?<=^)|(?<=[\s"'(]))"""
1071
+ r"""((?:[A-Za-z]:[\\/]|/)[^"'\s,;()]+\.[A-Za-z0-9]{1,6})""")
1072
+ found = re.findall(pattern, request or "")
1073
+ except Exception:
1074
+ return []
1075
+ missing = []
1076
+ for p in dict.fromkeys(found): # de-duplicate, keep order
1077
+ try:
1078
+ if not os.path.exists(p):
1079
+ missing.append(p)
1080
+ except Exception:
1081
+ continue
1082
+ return missing[:5]
1083
+
1084
+
1085
  def _summarise_success(request: str, results: list[StepResult]) -> str:
1086
  done = [r for r in results if r.ok and r.kind not in ("say",)]
1087
  if not done:
modules/operator_memory.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ modules/operator_memory.py — the ONE thing JARVIS knows about the operator.
3
+
4
+ WHY THIS EXISTS.
5
+
6
+ What he had learned about the person he works for was split across three stores
7
+ that never met:
8
+
9
+ 1. the PHONE's `JARVIS_MEMORY` (DataStore) — the "THEY: … || YOU: …" lines
10
+ written after every exchange. It reached exactly one place: `/api/chat`,
11
+ and only because the handset attached it to the request itself.
12
+ 2. the DESKTOP's `modules/memory.py` (.friday_data/memory.json) — insights and
13
+ unfinished tasks. Read by `core/brain.py`, and by nothing on the phone.
14
+ 3. the CLOUD's episodic memory — backed by ChromaDB, which is not installed
15
+ ("chromadb is not installed. Episodic memory will not work.").
16
+
17
+ So a fact told to him on the phone was invisible to the desktop brain, to the
18
+ autonomous initiative loop, and to research and OSINT. Asked whether he uses
19
+ what he has learned "for literally everything", the honest answer was no: he
20
+ used it on one path out of several, and the operator had no way to tell which.
21
+
22
+ This module is the single store all of those read from and write to. It is
23
+ deliberately plain — an append-only list of short lines with a source tag,
24
+ deduplicated, capped, on local disk. No database to install, nothing to be
25
+ absent at runtime, and nothing that fails differently on the Space than on the
26
+ desktop. The failure mode that mattered here was NOT a weak store; it was three
27
+ strong stores that could not see each other.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import re
35
+ import threading
36
+ import time
37
+ from typing import Optional
38
+
39
+ try:
40
+ from modules.paths import DATA_DIR # type: ignore
41
+ except Exception:
42
+ DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(
43
+ os.path.abspath(__file__))), ".friday_data")
44
+
45
+ _STORE = os.path.join(DATA_DIR, "operator_memory.json")
46
+
47
+ # Enough to carry a person, small enough to sit in every prompt without
48
+ # crowding out the actual question.
49
+ MAX_LINES = 400
50
+ PROMPT_LINES = 30
51
+
52
+ _lock = threading.Lock()
53
+
54
+
55
+ def _key(text: str) -> str:
56
+ """Normalise so the same fact told twice does not become two facts."""
57
+ return re.sub(r"[^a-z0-9]+", " ", (text or "").lower()).strip()[:160]
58
+
59
+
60
+ def _load() -> list[dict]:
61
+ try:
62
+ with open(_STORE, "r", encoding="utf-8") as fh:
63
+ data = json.load(fh)
64
+ return data if isinstance(data, list) else []
65
+ except Exception:
66
+ return []
67
+
68
+
69
+ def _save(rows: list[dict]) -> None:
70
+ try:
71
+ os.makedirs(os.path.dirname(_STORE), exist_ok=True)
72
+ tmp = _STORE + ".tmp"
73
+ with open(tmp, "w", encoding="utf-8") as fh:
74
+ json.dump(rows[-MAX_LINES:], fh, indent=2, ensure_ascii=False)
75
+ os.replace(tmp, _STORE) # atomic; a torn write loses everything
76
+ except Exception:
77
+ pass
78
+
79
+
80
+ def remember(text: str, source: str = "chat") -> bool:
81
+ """Record one thing learned. Returns True if it was new.
82
+
83
+ `source` is kept because provenance matters when he is asked how he knows
84
+ something — "you told me on the phone" and "I observed it on the desktop"
85
+ are different claims and should not be reported as the same.
86
+ """
87
+ line = (text or "").strip().replace("\n", " ")
88
+ if len(line) < 3:
89
+ return False
90
+ line = line[:400]
91
+ k = _key(line)
92
+ with _lock:
93
+ rows = _load()
94
+ if any(_key(r.get("text", "")) == k for r in rows[-MAX_LINES:]):
95
+ return False
96
+ rows.append({"at": time.time(), "text": line, "source": source[:32]})
97
+ _save(rows)
98
+ return True
99
+
100
+
101
+ def remember_many(lines, source: str = "chat") -> int:
102
+ """Absorb a batch — e.g. the whole block the phone sends each request."""
103
+ n = 0
104
+ for ln in (lines or []):
105
+ if remember(str(ln), source):
106
+ n += 1
107
+ return n
108
+
109
+
110
+ def absorb_block(block: str, source: str = "phone") -> int:
111
+ """Absorb the phone's `learned` blob, one line per remembered exchange."""
112
+ if not block:
113
+ return 0
114
+ return remember_many(
115
+ [ln for ln in str(block).splitlines() if ln.strip()], source)
116
+
117
+
118
+ def recall(limit: int = PROMPT_LINES) -> list[str]:
119
+ """The most recent things known, oldest first so the newest reads last."""
120
+ rows = _load()
121
+ return [r.get("text", "") for r in rows[-max(1, limit):] if r.get("text")]
122
+
123
+
124
+ def recall_text(limit: int = PROMPT_LINES, header: str = "") -> str:
125
+ """A prompt block, or '' when nothing is known.
126
+
127
+ Returning '' rather than a header with nothing under it matters: an empty
128
+ "WHAT YOU KNOW ABOUT THEM:" invites the model to fill the gap.
129
+ """
130
+ lines = recall(limit)
131
+ if not lines:
132
+ return ""
133
+ head = header or ("WHAT YOU HAVE LEARNED ABOUT THIS OPERATOR "
134
+ "(from every conversation, on every device)")
135
+ return f"{head}:\n" + "\n".join(f"- {ln}" for ln in lines)
136
+
137
+
138
+ def count() -> int:
139
+ return len(_load())
140
+
141
+
142
+ __all__ = ["remember", "remember_many", "absorb_block", "recall",
143
+ "recall_text", "count", "MAX_LINES", "PROMPT_LINES"]
modules/osint_review.py CHANGED
@@ -291,7 +291,34 @@ def run_osint_review(target: str = "") -> str:
291
  Executes the comprehensive OSINT & Attack Surface review.
292
  If 'target' is provided, it scans that specific asset.
293
  If 'target' is empty, it loads all assets from osint_targets.json.
 
 
 
 
 
 
 
 
294
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  try:
296
  from core.brain import _ask_gemini
297
  init_osint_targets()
@@ -328,7 +355,10 @@ def run_osint_review(target: str = "") -> str:
328
  with open(report_file, 'w', encoding='utf-8') as f:
329
  f.write(report)
330
 
331
- return f"OSINT & Attack Surface Review complete. The 14-section exhaustive report has been generated and saved to {report_file}."
 
 
 
332
 
333
  except Exception as e:
334
  _log.error(f"OSINT Review failed: {e}")
 
291
  Executes the comprehensive OSINT & Attack Surface review.
292
  If 'target' is provided, it scans that specific asset.
293
  If 'target' is empty, it loads all assets from osint_targets.json.
294
+
295
+ Collection is done by backend/omega/osint_engine.py — RDAP, certificate
296
+ transparency, DNS, Wayback, urlscan, OTX, InternetDB, GitHub, Gravatar,
297
+ Holehe-style account enumeration, Wikidata — and only the write-up is left
298
+ to the model, over findings it did not invent. Everything below the
299
+ delegation is the original prompt-only path, kept as the fallback for when
300
+ the engine cannot be imported at all; it produces a plausible-looking report
301
+ from model recall with nothing fetched, so it says so.
302
  """
303
+ try:
304
+ from modules.researcher import osint as _real_osint
305
+ if (target or "").strip():
306
+ return _real_osint(target.strip(), depth=2)
307
+ init_osint_targets()
308
+ with open(OSINT_TARGETS_FILE, "r", encoding="utf-8") as f:
309
+ bulk = json.load(f)
310
+ selectors = [
311
+ s for key in ("domains", "emails", "usernames")
312
+ for s in bulk.get(key, [])
313
+ if s and not s.startswith(("example.com", "admin@example.com", "target_alias"))
314
+ ]
315
+ if not selectors:
316
+ return ("No real targets configured. Give me a selector, or put "
317
+ f"domains/emails/usernames in {OSINT_TARGETS_FILE}.")
318
+ return "\n\n".join(f"── {s} ──\n{_real_osint(s, depth=1)}" for s in selectors[:5])
319
+ except Exception as _engine_exc:
320
+ _log.error(f"Real OSINT engine unavailable, falling back: {_engine_exc}")
321
+
322
  try:
323
  from core.brain import _ask_gemini
324
  init_osint_targets()
 
355
  with open(report_file, 'w', encoding='utf-8') as f:
356
  f.write(report)
357
 
358
+ return (f"OSINT review written to {report_file}. Flagging this honestly: "
359
+ f"the collection engine was unreachable, so this report is model "
360
+ f"recall — nothing in it was fetched from a source just now, and "
361
+ f"none of it is corroborated.")
362
 
363
  except Exception as e:
364
  _log.error(f"OSINT Review failed: {e}")
modules/researcher.py CHANGED
@@ -1,70 +1,176 @@
1
- """
2
- modules/researcher.py — FRIDAY Web Research
3
-
4
- Deep research on any topic:
5
- - Summarize topics
6
- - Quick facts
7
- - News summaries
8
- - Deep dive
9
- """
10
-
11
- def research(topic: str, depth: str = "quick") -> str:
12
- """Research a topic."""
13
- try:
14
- from config import GEMINI_API_KEY, GEMINI_MODEL
15
- if not GEMINI_API_KEY:
16
- return "No API. Search online instead."
17
-
18
- import google.generativeai as genai
19
- genai.configure(api_key=GEMINI_API_KEY)
20
- model = genai.GenerativeModel(GEMINI_MODEL)
21
-
22
- prompt = f"{'Quick summary' if depth == 'quick' else 'In-depth research'} on: {topic}. " + (
23
- "Answer in 3-4 sentences max." if depth == "quick" else "Comprehensive answer in 2 paragraphs with key points."
24
- )
25
-
26
- resp = model.generate_content(prompt)
27
- return resp.text.strip()[:500]
28
- except Exception as e:
29
- return f"Research failed: {e}"
30
-
31
-
32
- def quick_fact(question: str) -> str:
33
- """Get quick fact."""
34
- return research(question, "quick")
35
-
36
-
37
- def deep_dive(topic: str) -> str:
38
- """Deep research."""
39
- return research(topic, "deep")
40
-
41
-
42
- # ── Voice Commands ────────────────────────────────────────────
43
-
44
- def handle_command(command: str, speak) -> bool:
45
- """Handle research commands."""
46
- c = command.lower()
47
-
48
- # Research topic
49
- if "research" in c or "find out about" in c or "what is " in c or "who is " in c:
50
- topic = c.replace("research", "").replace("find out about", "").replace("what is", "").replace("who is", "").strip()
51
-
52
- if topic and len(topic) > 2:
53
- if "deep" in c or "detailed" in c or "in depth" in c:
54
- result = deep_dive(topic)
55
- else:
56
- result = quick_fact(topic)
57
-
58
- speak(result)
59
- return True
60
-
61
- # Quick question
62
- if "what's " in c or "who's " in c or "where " in c:
63
- # Basic factual questions
64
- topic = c
65
- if topic:
66
- result = quick_fact(topic)
67
- speak(result)
68
- return True
69
-
70
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ modules/researcher.py — desktop research, routed to the real engines.
3
+
4
+ This module used to be the whole of "research" on the PC: one Gemini call with
5
+ no web access, truncated to 500 characters. It answered from model recall and
6
+ called that research, which is exactly the complaint that the desktop "does
7
+ research like a normal model would". Meanwhile backend/omega/research_engine.py
8
+ (live search + fetch + analysis) and backend/omega/osint_engine.py (real
9
+ collection, pivoting and multi-source corroboration) were already serving the
10
+ phone and the Space.
11
+
12
+ So this file is now a thin router onto those two, and the offline fallback is
13
+ labelled as recall so nobody mistakes it for a source.
14
+ """
15
+
16
+ import logging
17
+ import re
18
+
19
+ _log = logging.getLogger(__name__)
20
+
21
+ # A person, a domain, an email, a handle — the things worth investigating rather
22
+ # than merely reading about.
23
+ _SELECTOR = re.compile(
24
+ r"(?:^|\s)(?:"
25
+ r"[\w.+-]+@[\w-]+\.[\w.]+" # email
26
+ r"|(?:https?://)?(?:[\w-]+\.)+[a-z]{2,}(?:/\S*)?" # domain / url
27
+ r"|@[\w.-]{3,}" # handle
28
+ r"|\d{1,3}(?:\.\d{1,3}){3}" # ipv4
29
+ r")(?:\s|$)",
30
+ re.IGNORECASE,
31
+ )
32
+
33
+ _OSINT_WORDS = (
34
+ "osint", "investigate", "dig into", "background check", "footprint",
35
+ "who owns", "whois", "breach", "leaked", "profile of", "trace",
36
+ )
37
+
38
+
39
+ def _run_async(coro, timeout: float = 240.0):
40
+ """Run a coroutine from sync code, loop or no loop. See tool_registry."""
41
+ import asyncio
42
+
43
+ try:
44
+ asyncio.get_running_loop()
45
+ except RuntimeError:
46
+ return asyncio.run(asyncio.wait_for(coro, timeout))
47
+
48
+ import concurrent.futures
49
+
50
+ def _worker():
51
+ return asyncio.run(asyncio.wait_for(coro, timeout))
52
+
53
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
54
+ return pool.submit(_worker).result(timeout=timeout + 30)
55
+
56
+
57
+ def _recall(topic: str) -> str:
58
+ """Last resort when nothing can be fetched. Labelled, never disguised."""
59
+ try:
60
+ from config import GEMINI_API_KEY, GEMINI_MODEL
61
+ if not GEMINI_API_KEY:
62
+ return ("I could not reach the web and I have no model key either, "
63
+ "so I have nothing sourced to give you on that.")
64
+ import google.generativeai as genai
65
+ genai.configure(api_key=GEMINI_API_KEY)
66
+ model = genai.GenerativeModel(GEMINI_MODEL)
67
+ resp = model.generate_content(
68
+ f"Answer concisely: {topic}. If you are not confident, say so."
69
+ )
70
+ return ("From memory, not from a source I just checked — "
71
+ + resp.text.strip())
72
+ except Exception as exc:
73
+ _log.error("Recall fallback failed", exc_info=exc)
74
+ return f"I could not research that: {exc}"
75
+
76
+
77
+ def research(topic: str, depth: str = "quick") -> str:
78
+ """Research a topic against the live web."""
79
+ topic = (topic or "").strip()
80
+ if not topic:
81
+ return "What would you like me to research?"
82
+ try:
83
+ from backend.omega.research_engine import research_topic
84
+ note = _run_async(research_topic(topic), timeout=240.0)
85
+ body = (getattr(note, "summary", "") or "").strip()
86
+ if body:
87
+ if depth == "quick":
88
+ # First two paragraphs is a spoken-length answer; the stored
89
+ # note keeps the whole thing for anything that wants to read it.
90
+ parts = [p for p in body.split("\n\n") if p.strip()]
91
+ if parts:
92
+ body = "\n\n".join(parts[:2])
93
+ action = (getattr(note, "recommended_action", "") or "").strip()
94
+ if depth != "quick" and action:
95
+ body += f"\n\nRecommended: {action}"
96
+ source = (getattr(note, "source", "") or "").strip()
97
+ if source:
98
+ body += f"\n\nSource: {source}"
99
+ return body.strip()
100
+ _log.warning("Research engine returned an empty note for %r", topic)
101
+ except Exception as exc:
102
+ _log.error("Research engine unavailable", exc_info=exc)
103
+ return _recall(topic)
104
+
105
+
106
+ def osint(target: str, depth: int = 2) -> str:
107
+ """Full investigation on a selector — the real collector, not a prompt."""
108
+ target = (target or "").strip()
109
+ if not target:
110
+ return "Give me a selector to investigate."
111
+ try:
112
+ from backend.omega.osint_engine import brief, investigate
113
+ report = _run_async(investigate(target, depth=depth), timeout=300.0)
114
+ if report.get("error"):
115
+ return f"OSINT failed: {report['error']}"
116
+ return brief(report)
117
+ except Exception as exc:
118
+ _log.error("OSINT engine unavailable", exc_info=exc)
119
+ return f"OSINT failed: {exc}"
120
+
121
+
122
+ def quick_fact(question: str) -> str:
123
+ return research(question, "quick")
124
+
125
+
126
+ def deep_dive(topic: str) -> str:
127
+ return research(topic, "deep")
128
+
129
+
130
+ # ── Voice Commands ────────────────────────────────────────────
131
+
132
+ _PREFIXES = (
133
+ "run osint on", "osint on", "osint", "investigate", "background check on",
134
+ "background check", "dig into", "research", "find out about", "find out",
135
+ "look up", "tell me about", "what is", "what's", "who is", "who's",
136
+ )
137
+
138
+
139
+ def _strip_prefix(text: str) -> str:
140
+ low = text.lower()
141
+ for p in _PREFIXES:
142
+ if low.startswith(p + " "):
143
+ return text[len(p) + 1:].strip(" ?.")
144
+ return text.strip(" ?.")
145
+
146
+
147
+ def handle_command(command: str, speak) -> bool:
148
+ """Handle research/OSINT commands. Returns False if this is not one.
149
+
150
+ Returning False matters: core/commands.py used to swallow every "what is"
151
+ and "who is" here whatever happened, so anything this module declined died
152
+ silently instead of reaching the brain.
153
+ """
154
+ text = (command or "").strip()
155
+ if not text:
156
+ return False
157
+ low = text.lower()
158
+
159
+ wants_osint = any(w in low for w in _OSINT_WORDS)
160
+ wants_research = any(
161
+ low.startswith(p) or f" {p} " in f" {low} " for p in _PREFIXES
162
+ )
163
+ if not (wants_osint or wants_research):
164
+ return False
165
+
166
+ topic = _strip_prefix(text)
167
+ if len(topic) < 3:
168
+ return False
169
+
170
+ if wants_osint or _SELECTOR.search(f" {topic} "):
171
+ speak(osint(topic))
172
+ return True
173
+
174
+ deep = any(w in low for w in ("deep", "detailed", "in depth", "thorough"))
175
+ speak(deep_dive(topic) if deep else quick_fact(topic))
176
+ return True
modules/tool_registry.py CHANGED
@@ -355,15 +355,92 @@ def _make_security_review_fn() -> Callable:
355
  return f"Security review failed: {str(_friday_exc)}"
356
  return execute_security_review
357
 
358
- def _make_osint_review_fn() -> Callable:
359
- def execute_osint_review(target: str = "") -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  try:
361
- from modules.osint_review import run_osint_review
362
- return run_osint_review(target)
 
 
 
363
  except Exception as _friday_exc:
364
- _log.error('Failed to run OSINT review', exc_info=_friday_exc)
365
- return f"OSINT review failed: {str(_friday_exc)}"
366
- return execute_osint_review
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
 
369
  def _build_default_tools() -> list[ToolSpec]:
@@ -513,13 +590,38 @@ def _build_default_tools() -> list[ToolSpec]:
513
  error_recovery='Return "Security review failed to initialize"',
514
  ),
515
  ToolSpec(
516
- name='run_osint_review',
517
- description='Executes an Elite OSINT and Attack Surface Reduction assessment on a target (domain, email, username). Pass empty string to bulk scan targets in osint_targets.json.',
 
 
 
 
 
 
 
 
518
  category='web',
519
- fn=_make_osint_review_fn(),
520
- input_schema={'target': 'str'},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  output_type='str',
522
- error_recovery='Return "OSINT review failed to initialize"',
523
  ),
524
  ]
525
 
 
355
  return f"Security review failed: {str(_friday_exc)}"
356
  return execute_security_review
357
 
358
+ def _run_async(coro, timeout: float = 300.0):
359
+ """Run a coroutine from a synchronous tool function.
360
+
361
+ The registry is called from two very different places: the desktop brain
362
+ (plain sync code, no loop) and the ReAct agent (already inside a running
363
+ loop). asyncio.run() explodes in the second case, so when a loop is already
364
+ turning in this thread the work goes to a private loop on a worker thread.
365
+ """
366
+ import asyncio
367
+
368
+ try:
369
+ asyncio.get_running_loop()
370
+ except RuntimeError:
371
+ return asyncio.run(asyncio.wait_for(coro, timeout))
372
+
373
+ import concurrent.futures
374
+
375
+ def _worker():
376
+ return asyncio.run(asyncio.wait_for(coro, timeout))
377
+
378
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
379
+ return pool.submit(_worker).result(timeout=timeout + 30)
380
+
381
+
382
+ def _make_osint_fn() -> Callable:
383
+ def execute_osint(target: str = "", depth: str = "2") -> str:
384
+ """Real collection, not a prompt.
385
+
386
+ The tool this replaced handed the target to Gemini and asked it to
387
+ "execute Phases 1-10 and output the final 14-section report" — no RDAP,
388
+ no certificate transparency, no DNS, no account enumeration, nothing
389
+ fetched from anywhere. The model wrote what an OSINT report looks like.
390
+ That is the "he does OSINT like a normal model" the operator reported,
391
+ and it was only ever true on the desktop: the phone and the Space have
392
+ been on backend/omega/osint_engine.py since it was written.
393
+ """
394
+ target = (target or "").strip()
395
+ if not target:
396
+ return ("OSINT needs a selector — a domain, email, username, IP, "
397
+ "phone number or a person's name. Give me one to work from.")
398
+ try:
399
+ depth_n = max(0, min(3, int(str(depth).strip() or 2)))
400
+ except Exception:
401
+ depth_n = 2
402
  try:
403
+ from backend.omega.osint_engine import brief, investigate
404
+ report = _run_async(investigate(target, depth=depth_n), timeout=300.0)
405
+ if report.get("error"):
406
+ return f"OSINT failed: {report['error']}"
407
+ return brief(report)
408
  except Exception as _friday_exc:
409
+ _log.error('Failed to run OSINT investigation', exc_info=_friday_exc)
410
+ return f"OSINT failed: {str(_friday_exc)}"
411
+ return execute_osint
412
+
413
+
414
+ def _make_image_forensics_fn() -> Callable:
415
+ def analyse_image_file(path: str = "", online: str = "false") -> str:
416
+ """Full forensic pass over a local image file.
417
+
418
+ `online` stays off unless the operator asks for it, because a reverse
419
+ image search PUBLISHES the picture to whichever engine answers it.
420
+ """
421
+ import os
422
+
423
+ path = (path or "").strip().strip('"').strip("'")
424
+ if not path:
425
+ return "Give me the path to an image file."
426
+ if not os.path.isfile(path):
427
+ return f"No such file: {path}"
428
+ want_online = str(online).strip().lower() in ("1", "true", "yes", "on", "online")
429
+ try:
430
+ with open(path, "rb") as fh:
431
+ data = fh.read()
432
+ from backend.tools.image_forensics import analyse_image, summarise
433
+ report = _run_async(
434
+ analyse_image(data, os.path.basename(path), online=want_online),
435
+ timeout=240.0,
436
+ )
437
+ if report.get("error"):
438
+ return f"Image analysis failed: {report['error']}"
439
+ return summarise(report)
440
+ except Exception as _friday_exc:
441
+ _log.error('Failed to analyse image', exc_info=_friday_exc)
442
+ return f"Image analysis failed: {str(_friday_exc)}"
443
+ return analyse_image_file
444
 
445
 
446
  def _build_default_tools() -> list[ToolSpec]:
 
590
  error_recovery='Return "Security review failed to initialize"',
591
  ),
592
  ToolSpec(
593
+ name='run_osint',
594
+ description=(
595
+ 'Real OSINT investigation on a selector (domain, email, username, IP, '
596
+ 'phone or person name). Collects from RDAP, certificate transparency, '
597
+ 'DNS, Wayback, urlscan, OTX, Shodan InternetDB, GitHub, Gravatar, '
598
+ 'Holehe-style account enumeration, Wikidata and Wikipedia, then pivots '
599
+ 'on what it finds and grades every assertion LEAD/CORROBORATED/'
600
+ 'CONFIRMED by how many independent sources carry it. depth 0=target '
601
+ 'only, 2=default, 3=deepest.'
602
+ ),
603
  category='web',
604
+ fn=_make_osint_fn(),
605
+ input_schema={'target': 'str', 'depth': 'str'},
606
+ output_type='str',
607
+ error_recovery='Return "OSINT failed to initialize"',
608
+ ),
609
+ ToolSpec(
610
+ name='analyse_image',
611
+ description=(
612
+ 'Forensic analysis of an image file on this PC: all metadata (EXIF/GPS/'
613
+ 'XMP/IPTC/maker notes), hidden data (LSB steganography, appended '
614
+ 'payloads, embedded file magics), watermarks (visible, frequency-domain '
615
+ 'and invisible), error level analysis, copy-move/clone detection, noise '
616
+ 'consistency, perceptual hashes and face count. Pass online=true to also '
617
+ 'reverse image search and geolocate — that publishes the picture, so only '
618
+ 'do it when the operator asked.'
619
+ ),
620
+ category='file',
621
+ fn=_make_image_forensics_fn(),
622
+ input_schema={'path': 'str', 'online': 'str'},
623
  output_type='str',
624
+ error_recovery='Return "Image analysis failed to initialize"',
625
  ),
626
  ]
627