Spaces:
Sleeping
Sleeping
deploy(S4): Blender headless pipeline + WebAR client + backend fixes (part 2)
Browse files- modules/initiative.py +122 -14
- modules/input_control.py +247 -1
- modules/intent.py +1 -1
- modules/omega_executor.py +436 -7
- modules/operator_memory.py +143 -0
- modules/osint_review.py +31 -1
- modules/researcher.py +176 -70
- modules/tool_registry.py +114 -12
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=
|
| 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 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 562 |
-
|
| 563 |
-
|
|
|
|
| 564 |
record_done(verdict.action, res.spoken, reversible=not risky)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
except Exception:
|
| 566 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 538 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 —
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
try:
|
| 361 |
-
from
|
| 362 |
-
|
|
|
|
|
|
|
|
|
|
| 363 |
except Exception as _friday_exc:
|
| 364 |
-
_log.error('Failed to run OSINT
|
| 365 |
-
return f"OSINT
|
| 366 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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='
|
| 517 |
-
description=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
category='web',
|
| 519 |
-
fn=
|
| 520 |
-
input_schema={'target': 'str'},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
output_type='str',
|
| 522 |
-
error_recovery='Return "
|
| 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 |
|