On this page
BUGS.md
Bug log
Open bugs
2026-10-03 — Phone (Tank 3 Pro): tapping Signal Walk crashed Aegion (NoClassDefFoundError ViewBinding) — FIXED (0.2.2)
Why: on 0.2.1 the intent fix worked (20:05:37 app opened Signal Walk), but the process died 3 s later. dumpsys activity exit-info showed APP CRASH (EXCEPTION), and dumpsys dropbox --print data_app_crash had the stack. logcat had already rolled past it, flooded by the vendor fingerprint SIGSEGV.
java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/viewbinding/ViewBinding;
at android.app.AppComponentFactory.instantiateActivity ... performLaunchActivity
Caused by: java.lang.ClassNotFoundException: Didn't find class "androidx.viewbinding.ViewBinding"
walklib builds with buildFeatures { viewBinding = true }. That adds androidx.databinding:viewbinding:<AGP version> to walklib's own build implicitly, so it is not an implementation(...) line. An AAR carries no dependencies, and android.gradle_dependencies copied only the explicit ones. The generated *Binding classes in the AAR implement ViewBinding, so MainActivity could not even be instantiated.
Checked and fine: the merged activity has android:theme="@style/Theme.SignalWalk" (parent Theme.MaterialComponents.DayNight.NoActionBar), and the style resource is in the APK. The AAR's classes refer only to kotlin, recyclerview, fragment, lifecycle, activity, core, appcompat, material, annotation (all covered by the declared dependencies) and viewbinding.
Fixed:
mobile/buildozer.specaddsandroidx.databinding:viewbinding:8.11.0(AGP 8.11.0, the same in~/UNM/androidand the p4a dist; already in the Gradle cache). No change to walklib or the standalone app.test_shield_bg.pynow counts viewbinding at the AGP version as a walklib dependency wheneverviewBinding = true, so this can't drift again.- Version 0.2.2.
Verified:
test_shield_bg102/102,test_signalwalk125/125,test_plat60/60.- The 0.2.2 build (
mobile/dist/aegis-mobile-0.2.2-arm64-v8a-debug.apk) is installed withinstall -r, versionName 0.2.2. It starts cleanly to the Choose page with the safe-area line logged. - dexdump: 0.2.2 defines
androidx.viewbinding.ViewBindingnext toorg.unm.signalwalk.MainActivityandActivityMainBinding. 0.2.1 had no definition. - Not yet tried on the phone: opening Signal Walk. The activity is not exported, so
adb shell am start(even throughrun-as) is refused, and UI taps were not allowed. The user's next tap will confirm it.
2026-10-03 — Phone (Tank 3 Pro): "Signal Walk" button does not open the built-in Signal Walk — FIXED (0.2.1)
Why: on the installed 0.2.0, aegis-log.txt shows Signal Walk not opened: TypeError: Invalid instance of 'org/kivy/android/PythonActivity' passed for a 'java/lang/String'. main.py open_signal_walk() calls intent.setClassName(activity, SIGNAL_WALK_ACTIVITY). Intent.setClassName has both (Context, String) and (String, String) overloads, and pyjnius picks the (String, String) one, so it rejects the activity. The activity itself is in the APK: org.unm.signalwalk.MainActivity and WalkService merged into the manifest from libs/signalwalk.aar.
Fixed: intent.setClassName(activity.getPackageName(), SIGNAL_WALK_ACTIVITY), and test_shield_bg.py checks the new call. Version 0.2.1. The other jnius calls added this session were checked and need no change:
AegisBridge(walk_command,_signal_walk_back,signalwalk.walk_line): every method is static with a single signature takingContext/Activity(javap on the AAR).plat.android_insets():Settings.Secure.getInt(resolver, name, default)has one three-argument overload, andgetInsets(int)takes the int. 0.2.0 logged the right safe area.signalwalk.default_db():getExternalStoragePublicDirectory(String)has one signature.widgets.open_url:Intent(ACTION_VIEW, uri)takes a Python string, which cannot match(Context, Class).
Verified: test_shield_bg 102/102, test_signalwalk 125/125, test_signalwalk_ui 41/41, test_plat 60/60, test_themes 120/120.
2026-10-03 — APK build: minapi 29 above ndk_api 26 stopped python-for-android — FIXED
Why: buildozer android debug for 0.2.0 failed at packaging: "--minsdk argument does not match the api that is compiled against … You must pass --allow-minsdk-ndkapi-mismatch". android.minapi was raised to 29 for libs/signalwalk.aar, while android.ndk_api (and the cached dist's native libraries) stayed at 26.
Fixed: mobile/buildozer.spec sets p4a.extra_args = --allow-minsdk-ndkapi-mismatch. Native code compiled for API 26 runs on 29+. Raising ndk_api to 29 instead would rebuild every recipe (Python, Kivy, SDL2, OpenSSL) on a 7.8 GB laptop.
Also: sdkmanager and Gradle are Java and ignore ALL_PROXY. On this network a direct connection to dl.google.com times out and the SOCKS proxy works, so sdkmanager hung at "Fetch remote repository". The build ran with JAVA_TOOL_OPTIONS="-DsocksProxyHost=10.98.59.9 -DsocksProxyPort=1080" (no script change). Likewise curl localhost:8000/health needs --noproxy '*' while ALL_PROXY is set.
Verified: test_plat 60/60, test_shield_bg 102/102, test_guardian 38/38, test_themes 120/120, test_phrases 139/139. Build OK in about 8 min (mobile/dist/aegis-mobile-0.2.0-arm64-v8a-debug.apk, 44 MB). adb install -r on the Tank 3 Pro succeeded and shows versionName 0.2.0. The app starts and logs "safe area (110, 0) px, nav 2: top 128 px, bottom 48 px", with no crash in logcat.
2026-10-03 — Phone (Tank 3 Pro): header under the status bar, the Shield service idling on Android's allowance, the hidden browser failing in the service — FIXED
Why: a read-only analysis of the Tank 3 Pro (0.1.19, /tmp/tank3pro-analysis/REPORT.md) found:
main.pypadded the top bydp(28)= 84 px, but the status bar and camera hole take 110 px (37 dp at density 3.0), so 26 px of the header sat under them. The spec page claimed 36 dp / 28 dp, which matched neither.shield_service.jsonhad read "Session limit · 120 questions" since 17:40, yet the dataSync foreground service kept running and counting against Android 15's 6-hour allowance.ServiceLoop._watchstopped only for app back, Auto-train off, allowance used up or stretch time. Idle days like this used up the allowance on 30 Sep, which led to the crashes in the entry below.aegis-log.txthad 57 browser-reader failures in 30 minutes, allClassNotFoundException: org.jnius.NativeInvocationHandler, from[bg]lines. pyjnius cannot load its proxy class in the:service_shieldprocess, and there is no Activity there to host a WebView anyway.
Fixed:
- Safe area.
engine/plat.pyhassafe_area(density, insets_px, nav_mode)andandroid_insets(). The second readsgetRootWindowInsets().getInsets(systemBars | displayCutout)(API 30+, else the old system-window insets) andSettings.Secure navigation_modethrough jnius, and fails softly.- Measured insets get 6 dp of breathing room (at least 24 dp on top and 16 dp below).
- Fallbacks: Android 40 dp / 24 dp, or 48 dp below with button navigation; iPhone 44 / 34 dp; desktop 12 / 10 dp.
main.py:safe_topandsafe_bottomare nowNumericPropertys. They are re-read 0.3 s and 1.5 s after start and on every resume, and the result is logged as "safe area …".- The spec page (
make-spec-html.py) states the real numbers. - Shield service.
engine/shield_bg.pyhasspent(trainer). Whentrainer.gate()is a session limit or "Done for today" and no tutorial the user queued can still be built, the service stops with "<gate>; nothing more to learn in the background".idle_today(data):main.py _service(True)does not start the service again on a day the last run stopped at the day's cap.- Never restarting once the allowance is used up was already in place.
_servicechecksAllowance.left() < MIN_RUN_S, the service is not sticky, andShieldServiceBasestops on a refusedstartForegroundand ononTimeout. - Browser reader.
engine/browser_reader.py available()is False in a service process, detected byPYTHON_SERVICE_ARGUMENT(set by p4a) or bymark_service(), whichshield_service.pycalls first. The engine's plain fetches stand in. - Manifest
<queries>forandroid.intent.action.TTS_SERVICEandandroid.speech.RecognitionService. They go inmobile/android_manifest_extra.xmlthrough buildozer'sandroid.extra_manifest_xml, which p4a puts inside<manifest>: no template change. It isn't needed on the Tank 3 Pro (Google's voice and recognizer are force-queryable), but it is for other phones.
Verified:
test_plat60/60 checks the safe-area numbers for the Tank 3 Pro, the fallbacks, button navigation, iPhone, desktop and the soft failure.test_shield_bg102/102 checks that the session limit and the day's cap stop the service, the status file,idle_todaytoday and tomorrow, that a queued user tutorial keeps it going, that a charger wait does not stop it, themain.pywiring and the<queries>file.test_ingestion_part199/99 checks that the service process gets no hidden browser and thatshield_service.pymarks its process.- Not tried on the phone (needs the 0.2.0 build): the real inset read, and the service stopping at the session limit.
2026-10-03 — One app everywhere: Aegion on Linux, macOS, Windows, Android and iPhone — ADDED
Why: the user asked to merge the Ubuntu system's apps into one application "for desktop and android and apple devices", then to call it Aegion.
Added (details in HANDOFF.md section 20):
- Platform layer.
engine/plat.pyandengine/plat_ios.py. The latter uses pyobjus for AVSpeechSynthesizer, AVAudioRecorder + SFSpeechRecognizer andopenURL:options:. - Desktop app.
./aegisopens the Kivy app with the laptop backend, Kokoro (engine/speech.py) and the Artemis worker as the microphone (engine/desktop_voice.py). It adds a Laptop tab (ui/laptop.py,engine/laptop.py)../aegis --classickeeps the GTK window for one release. - Fast paths. Weather, exchange rates and city times (
engine/livedata.py, live, never stored). - Signal Walk everywhere.
mobile/signalwalk/is vendored from~/UNM/signalwalk(sync-signalwalk.sh).engine/walkview.pyandui/signalwalk.py: walks, radar, devices, Ask the log and Export JSON. On desktops also a read-only full map server (port 8765) and Pull from phone (adb).- The Android recorder (AAR) is unchanged.
- LAN sync.
- Laptop side:
lan_sync.py, the/sync/pair,/sync/pack,/sync/teachingsand/sync/walksroutes inhandshake_routes.py, and a pairing-code guard middleware inmain.py(only/healthis open to other computers). The Handshake Settings tab gets a "Phone link" card (static/aegis-pair.js). - App side:
engine/lan_sync.pyand Settings > LAPTOP LINK > Sync now. A synced pack goes to the data folder (engine/pack.py choose).
- Merged apps.
- The Guardian scanner (
mobile/guardian/,sync-guardian.sh,engine/guardian_scan.py,ui/guardian.py) is on the Shield screen on Linux. - Markets tab (
engine/portfolio.py,ui/markets.py; watchlist, holdings, broker CSV import) replaces StockTrack. Its three launchers were backed up and removed. - SignalDub Studio card (
engine/studio.py).
- iOS.
buildozer.spec:ios.keys,ios.plist.usage strings, and an[app@ios]profile with pyobjus.mobile/ios-plist.pycopies the strings into the Xcode Info.plist.mobile/BUILD-IOS.mdand an iOS section on the spec page.
- Packaging.
- The app-menu launcher is now Aegion, running
./aegis(with a Classic action). mobile/RUN-DESKTOP.mdcovers running from source on macOS and Windows.desktop_shots.py --oneapptakes screenshots.- Version 0.2.0.
- Name.
- Changed to Aegion: the window title,
titleinbuildozer.spec, every style's boot/Choose word, the about answers ("what is aegion"; Artemis is now the wake word), the restart notice, the logs header, and the iOS display name. - Unchanged: package id
online.mindflux.aegis, the classAegisMobile(Kivy's data folderaegismobile), the module names,./aegis, and the feature names Aegis Engine, Artemis, Shield of Zeus and Signal Walk.
Verified:
- All 33 phone/app suites pass. New:
test_plat60,test_laptop_link41,test_fastpaths29,test_signalwalk_ui41,test_sync39,test_guardian38. - The live backend's
/sync/pair,/sync/pack(2091 docs) and/sync/walkswork. full_test.py: 18/28. Eight are 20 s client timeouts on the network-bound handlers (stocks, weather, currency, the LLM, news) while the proxy was failing. Two are expected-qtype labels on correct answers ("capital of Norway" isfact, Marie Curie is auser_correction). No changed code is on those paths.- Screenshots:
handoff-out/aegion-shots/. - Not done: the APK build (run
mobile/build-apk.shwith Cursor closed) and any iOS build (needs a Mac). The backend still binds 127.0.0.1, so phone sync over Wi-Fi needs--host 0.0.0.0(RUN-DESKTOP.md). SignalDub needs FFmpeg, which is not installed.
2026-10-03 — Phone: Signal Walk voice and engine integration (Artemis runs it, the Aegis Engine knows its walks) — ADDED
Why: the user asked for Aegis's voice operation in Signal Walk as well, "a total system". Signal Walk was only a screen opened from Aegis: Artemis couldn't open it or run a walk, and the engine knew nothing about the walks.
Added:
engine/voice_commands.py:- a
signalwalknavigation target ("open Signal Walk", "start Signal Walk app"); walk_command()for "start a walk" / "start Signal Walk", "stop the walk" and "is the walk running" (actionwalk, checked before navigation so "start Signal Walk" starts a walk). A bare "stop" still only stops speech.engine/artemis.py: both actions are explicit commands, with eventssignal_walk(opens it and says "Opening Signal Walk.") andwalk(app.walk_command, whose line is spoken).engine/signalwalk.py(new):- The engine stage reads
Download/signalwalk/walks.dbread-only (file:URI withmode=ro, 2 s timeout), with the path taken from Android's Environment. - It answers: how many devices / Wi-Fi networks / Bluetooth devices / towers (all walks or the last one), the strongest Wi-Fi, Bluetooth or tower nearby, "how far is <name>" (the stored
estimated_distance_ft, or RadioEstimate's formula), "summarize my last walk" (time, length, distance walked, counts, strongest Wi-Fi), "how many walks", and "what is Signal Walk" (with its commands). - It only takes clearly Signal Walk questions. "how far is X" needs X to be a logged radio, or the question to say Wi-Fi, Bluetooth, device or Signal Walk.
- A missing or unreadable file gets an answer that says to allow All files access.
walk_line()gives the spoken line for each walk command.engine/answer.py: the stage runs right after your own teachings and the unfinished-question check (qtypesignalwalk, stage "Signal Walk"). "that's wrong" after one says it can't be removed.engine/provenance.py: asignalwalkorigin with the SIGNAL WALK chip.engine/autotrain.py:signalwalkis inKNOWN_QTYPES, so these questions are never retried as misses.main.py:walk_command()callsorg.unm.signalwalk.AegisBridgeand puts the line on the Ask page;on_pausealso stops a Voice-button dictation, after Artemis lets go of the mic;on_resume→_signal_walk_back()→ask_by_voice().ui/engine_page.py:listen(send=True)asks the heard question and reads the answer aloud. The Ask page hint mentions Signal Walk.engine/about_app.py: the abilities text mentions Signal Walk.~/UNM/android/app:AegisBridge.kt(new,@JvmStatic):startWalk(or what is missing),stopWalk,running,counts,openToBegin,requestAsk/takeAskandhosted. The permission list, labels and storage check moved here fromMainActivity.MainActivity:EXTRA_BEGINruns the Start walk flow on resume, andaskArtemis()hands back to Aegis.WalkFragmentandfragment_walk.xml: an ASK ARTEMIS button, shown only inside Aegis. Two new strings.mobile/libs/signalwalk.aarrebuilt (216 KB).
Verified:
:walklib:assembleRelease(viabuild-signalwalk-aar.sh) and the standalone:app:assembleDebugboth pass (offline,--no-daemon,-Xmx1g).test_signalwalk125/125. Its fixturewalks.dbis built fromWalkStore.kt's CREATE statements. It covers the voice commands, the spoken lines, each question type, 18 general questions that must not be captured, the missing/unreadable/empty files, a walk in progress, read-only access, the pipeline order, provenance, KNOWN_QTYPES and the Kotlin wiring.test_shield_bgalso checksAegisBridge.classand the button in the AAR.- py_compile and the KV parse pass. All 27 phone suites pass (
test_shield_bg91/91,test_artemis,test_provenance20/20,test_autotrain92/92,test_misses125/125, and the rest). - Not tried on the phone: the buildozer build, the mic hand-back timing (
ASK_BACK_S0.6 s) and starting the walk service from Aegis.
2026-10-03 — Phone: Signal Walk merged into Aegis (one app) — ADDED
Why: the user asked for Signal Walk (~/UNM/android, native Kotlin, until now its own APK org.unm.signalwalk) to be part of Aegis as one app, with no second install. This replaces the same-day card that opened the separate app (android_queries.xml, the "not installed" note).
Added:
~/UNM/android/walklib(new Gradle library module, insettings.gradle.kts):- It builds
app/'s Kotlin sources and resources into an AAR, so the code is not copied. - Its manifest has
MainActivity(not exported, no launcher entry, label@string/walk_title, themeTheme.SignalWalk) andWalkService(location|connectedDevice), plus the same permissions asapp/. com.android.libraryis applied without a version, because the rootcom.android.application8.11.0 already loads AGP and the offline cache has no library plugin marker.extractAnnotationsis disabled (it needslint-gradle, not in the offline cache), and a small task writes the emptytypedefs.txtthatsyncLibJarsrequires.~/UNM/android/app: awalk_titlestring for the walk screen header (fragment_walk.xml).app_nameclashed with p4a's ownapp_name, which wins in the merged APK.mobile/libs/signalwalk.aar(209 KB) andmobile/build-signalwalk-aar.sh(offline:walklib:assembleReleasewith the Gradle memory limits, then copies the AAR).buildozer.spec:android.add_aars = libs/signalwalk.aar;android.add_gradle_repositories = flatDir { dirs 'libs' }(the p4a template has noflatDir, andadd_aarscopies the AAR into the dist'slibs/);android.gradle_dependencieswith kotlin-stdlib 2.1.20, core-ktx 1.15.0, appcompat 1.7.0, recyclerview 1.3.2 and material 1.12.0 (an AAR does not carry its dependencies);android.enable_androidx = Trueandandroid.minapi = 29(the library's minSdk);libsinsource.exclude_dirs.extra_manifest_xmlis removed andandroid_queries.xmlis deleted.main.py:SIGNAL_WALK_ACTIVITYreplacesSIGNAL_WALK_PACKAGE;open_signal_walk()startsIntent().setClassName(activity, "org.unm.signalwalk.MainActivity")in Aegis's own task, so Back returns to the selection page. There is noNEW_TASKand no package lookup, and errors are still logged and shown on the card.ui/choose.py: the card says "Part of Aegis; Back returns here." The badge only shows "didn't open" on an error.- While Signal Walk is on top, Aegis pauses as it would for any other app: Artemis goes to the background and the Shield service takes the data (Android 15's 6-hour dataSync allowance applies), then takes it back on return.
- Walks still go to
Download/signalwalk/walks.db, which needs "All files access" for Aegis. Signal Walk asks for it under Aegis's package.
Verified:
- Gradle (offline,
--no-daemon,-Xmx1g, in-process Kotlin)::walklib:assembleReleaseand:app:assembleDebugboth pass, so the standalone app still builds. - The AAR manifest has no LAUNCHER entry, and its only resource that clashes with p4a is
app_name, which onlyapp/'s manifest uses. test_shield_bg90/90 (the Signal Walk checks were rewritten for the AAR, the spec and the launcher).test_themes120/120,test_storage45/45, the choose KV parses, and all 26 phone suites pass.- Not tried: the buildozer build (it needs the network for the Gradle dependencies) and the phone. The old
org.unm.signalwalkcan stay installed or be uninstalled; the two do not share data apart fromwalks.db.
2026-10-03 — Shared archive: every Aegis phone downloads checked facts, and phones that agree help build them (PHP + MySQL server) — ADDED
Why: the user asked for every Aegis phone to work together on one archive of facts, coordinated by an API on their own web server and SQL database. Each phone learned alone, so the same subjects were researched again on every phone.
Choices (the user's): PHP with MySQL. Anyone who installs Aegis can join. The server re-checks every contribution against Wikidata and Wikipedia itself and counts how many devices agree. Every phone downloads automatically; contributing needs the user's consent.
Design: phones point, the server fetches.
- A contribution is only
{qid, title, pids, about}: the Wikidata item and property numbers a phone confirmed, the article title, and whether the lead was kept. It goes with a random device id and token fromv1/register. Questions, answers, teachings and typed text never leave the phone. server/verify.php(cron) fetches the Wikidata entity and the Wikipedia intro itself and runslib/check.php, a port ofcuriosity.check_article. It publishes only values from its own fetch, so contributed text is never published.- Subjects that published facts name are queued for checking too, and every published subject is re-checked after 90 days. A value that no longer holds is withdrawn, and the withdrawal syncs to the phones.
- Abuse limits:
- Per-IP registration, per-device and per-IP contributions, per-IP reads, and leases, all per day or hour (
config.phplimits). - A device with at least 50 refs, more than 60% of them rejected, is suspended.
admin.php(password plus CSRF) withdraws facts and suspends or restores devices.- Downloading is private:
v1/facts?since=delta sync (aseqchange counter), andv1/bucket/{4 hex}for a name not on the phone: the first 4 hex digits of the sha256 of the name, so the server never learns which name or question.
Added:
server/(PHP 8 with PDO MySQL; curl optional):schema.sql(devices, subjects, names, refs, ref_devices, facts, counters, rate);config.sample.php,.htaccess,.gitignore,README.md(install, cron, API, verification, admin);api.php:v1/status,v1/facts,v1/bucket/{b}(GET);v1/register,v1/lease,v1/contribute(POST);verify.php(CLI or?key=web cron, flock lock),admin.php;lib/check.php,lib/wiki.php(maxlag and retries),lib/store.php(MysqlStoreplusMemStorefor tests),lib/archive.php,lib/bootstrap.php;- tests:
tests/make_vectors.py(cases computed by the phone's Python),tests/test_check.php,tests/test_api.php(--mysql DSN USER PASSruns it on a real database). - Phone,
engine/archive.py(stdlib only): - the local copy is
shared.sqlitebeside the other data, trimmed oldest-first toarchive_max_mb(50; 5 to 500); - the queue
archive_queue.jsonsurvives offline periods and restarts; - 401 forgets the account, 403 stops contributing, 429/5xx/offline back off 1 h;
ARCHIVE_URLis empty, so the archive does nothing until an address is set before a build or in Settings.engine/answer.py: Engine opens the archive beside the data._lookupasks it right after stored answers: before saved tutorials, saved web pages, the library and the web. A stand-in web withoutarchive_fetchkeeps it off the network.engine/provenance.py: qtypearchive, labelled "Shared archive", with the new chipSHARED ARCHIVE.engine/autotrain.py:archivecounts as known;- an
archivejob syncs every 6 h and sends queued contributions, at most one every 15 min, under every Auto-train gate; - direct ingestion queues what the article confirmed, and so does a curious answer that agrees with Wikidata;
- a subject the archive already has is not read again, and only what it lacks is asked;
- with consent, curiosity leases up to 12 batches a day of subjects nobody researched lately while fewer than 20 topics wait;
- the Trainer card shows the archive line.
- Settings:
archive_download(on),archive_contribute(off),archive_max_mb,archive_url,archive_asked, all inmain.pyDEFAULTSandshield_bg.BACKGROUND_DEFAULTS;- a "Shared archive" card (two switches, the address and the size) with its status line;
- a one-time consent dialog (
ui/widgets.ArchiveConsent), shown only when an address is set, saying exactly what is sent and what never is; - turning Contribute off drops the queue.
engine/storage.py: a "Shared archive" row in Storage (shared.sqlite,archive_queue.json).- About texts (
main.pyabout line,about_app.ARTEMIS,make-spec-html.py) no longer say nothing leaves the phone. buildozer.spec0.1.20.
Verified:
php server/tests/test_check.php354/354. The PHP check gives the same results as the phone's Python on 8 subjects, plus junk titles, descriptions and name keys.php server/tests/test_api.php76/76 (MemStore, fake Wikipedia and Wikidata). It covers:- only the server's own values are published;
- delta sync and withdrawals;
- buckets;
- rate limits;
- leases;
- reopening refs after 30 days;
- suspension;
- the admin actions.
php -Ssmoke test: both routing styles, both auth headers, verify without a key refused, admin login. No PHP warnings.mobile/test_archive.py70/70 (new, stand-in server). It covers:- sync and withdrawals;
- the size cap;
- bucket privacy (only 4 hex digits leave the phone);
- nothing queued, sent, registered or leased without consent;
- the queue surviving offline periods and restarts;
- 401, 403 and 429;
- Auto-train sync, flush, lease and skip;
- the Engine order: stored answer, then archive, then web;
- the provenance chip;
- the defaults and the dialog text.
- All 26 phone suites pass. KV parses with Kivy 2.3.1.
- Not tried: a real MySQL server (none on this laptop: run
php tests/test_api.php --mysql ...on the server), the deployed server, and the dialog on the phone (needs the 0.1.20 build).
2026-10-03 — Phone: the Shield of Zeus background service crashed on Android 15 (data-sync time limit, sticky restarts) — FIXED
Why: a read-only log pull from the Tank 3 Pro (Android 15, 0.1.17, dropbox crash records) showed :service_shield crashing three times on 30 Sep:
- 21:39:06, after a 4 h run:
ForegroundServiceDidNotStopInTimeException: A foreground service of type dataSync did not stop within its timeout. - 21:39:08:
ForegroundServiceStartNotAllowedException: Time limit already exhausted for foreground service type dataSync. - 22:09:08:
ForegroundServiceStartNotAllowedException: Service.startForeground() not allowed due to mAllowStartForeground false.
Cause:
- Android 15 (targetSdk 35) gives a
dataSyncforeground service 6 hours in all. When the 6 hours run out it callsService.onTimeout()and, 10 s later, crashes a service that is still running. - The count is not per run (AOSP
ActiveServices/ServiceRecord.TimeLimitedFgsInfo): - Time awake (uptime) adds up over every run.
- It starts over only when a run starts 24 h after the previous run started, or after a reboot.
- Android resets it when the service's own process is on screen. That process is
:service_shield, which is never on screen, so opening Aegis never reset it. HANDOFF said it did. - 0.1.17's limit was 5.75 h per run, so the runs since the 15:23 install added up to 6 h. Most of the last run was spent idle ("Session limit", "Waiting for the charger") while holding the service.
- python-for-android 2026.05.09's
PythonServicehas noonTimeout, so Python never heard about it. stickymade Android restart the crashed service from the background. p4a callsstartForeground()inonStartCommand, and Android refused it: first because the time was used up, then because Android 12+ blocks starting a foreground service from the background. Each refusal crashed the process.
Options weighed:
specialUsehas no time limit, but it does not fit: the work is fetching and indexing pages, which is whatdataSyncis for. p4a's manifest template also cannot put the required<property>inside the generated<service>.- A scheduled job (JobScheduler or WorkManager) cannot start a foreground service from the background on Android 12+, and p4a has no job hook.
- Dropping
stickyalone fixes crashes 2 and 3, but p4a'sonTaskRemovedthen stops a non-sticky service on a swipe-away. Background learning that survives a swipe-away is a user requirement (2026-09-27).
Fixed:
buildozer.spec:services = shield:shield_service.py:foreground:foregroundServiceType=dataSync: stilldataSync, no longersticky.android.add_src = javaandandroid.service_class_name = online.mindflux.aegis.ShieldServiceBase.- New
mobile/java/online/mindflux/aegis/ShieldServiceBase.java, the base class of the generatedServiceShield: onTaskRemoveddoes nothing, so a swipe-away still leaves it running.doStartForegroundcatches a refusedstartForeground()and callsstopSelf()instead of crashing.onTimeout(int, int)(API 35) callsstopSelf().- Compiles against API 26+ (no
@OverrideononTimeout). engine/shield_bg.py:- New
Allowance(fileshield_allowance.json) counts the way Android does:time.monotonicfor uptime,CLOCK_BOOTTIMEfor the 24 h window, and the boot id for reboots. ServiceLoop(allowance=)writes the time used every 60 s and at the end. It stops at 6 h less a 15-minute margin, with "Android's 6-hour background allowance is used up; it starts over from <day hh:mm>", and logs the time left at start.fgs_limited()is true on API 35+.shield_service.pypassesAllowance(data)on Android 15+. Older Android keeps the 5.75 h per-run limit only.main.py_service(True): on Android 15+, the service is not started with under 10 minutes left (MIN_RUN_S). It logs why and returns False, so the app falls back as when there is no service. App-side start errors were already caught.
Verified:
test_shield_bg85/85 (27 new checks):- the allowance adds up over runs, counts time awake only, is still used up 23 h after the last start and is whole at 24 h or after a reboot;
- checkpoints are written at most every 60 s, and an unreadable file counts as whole;
- the loop stops before the allowance runs out, saves the time used and releases the lock;
- the spec has no
stickyand keepsdataSync, and the Java class matchesservice_class_name; main.pychecks the allowance before starting.ShieldServiceBasewith the p4a-renderedServiceShield(no sticky) and the whole dist Java tree compiles withjavacagainstandroid-35.jarandandroid-33.jar.onTimeout(int, int)overrides API 35'sService.onTimeout.- All 25 phone suites pass;
py_compileis clean. - Not tried on the phone: needs the 0.1.19 build.
- Trade-offs:
- Background learning is capped at about 5.75 h until 24 h after the last background start. Time spent waiting for the charger counts.
- When Android kills the service for memory, it is no longer restarted (that restart crashed on Android 12+ anyway).
2026-10-03 — Curiosity engine: Aegis finds subjects in what it reads, ingests them and asks checked questions, by itself — ADDED
Why: the user asked for Aegis to keep gathering factual knowledge without being prompted, from relevant questions only: nothing invented, everything traced to something Aegis actually read. Auto-train on the phone asked only from a fixed list; the laptop had only one-off batch scripts.
Relevance rule (no LLM anywhere): a question is asked only when all four hold:
- The subject is real: an English Wikipedia article with a Wikidata item, not a disambiguation page, list, year or date.
- The fact exists: one question per Wikidata property the item actually has (born, died, capital, population, elevation, area, founded, founder, inventor, creator, country, mass, orbital period), plus "what is X".
- Aegis found it: in a page, answer, tutorial, lead section or fact it read, at most 2 steps from your questions or from what it already knows.
cross_checkagrees: the answer's year, number (2%, population 10%) or name matches the Wikidata value.
Added:
mobile/engine/curiosity.py(stdlib only, shared by the phone and the laptop):- Discovery:
found_in_text,found_in_lead,found_in_facts,found_in_tutorial; seeds from your questions, your misses and the daily Wikipedia feed. ground(): batchedwbgetentities(50 titles a call, then claims 10 a call, then the labels of the items they name).PROPERTY_QUESTIONS,questions_for,cross_check,check_article(each claim against the article: confirmed, pending or contradicted).Frontier(plain JSON): mentions, anchor bonus, facts left, yield per scan type and property (under 10% kept rests a day), kinds taken in turn, banned and asked lists, depth 2, 300 topics.- Direct ingestion first, for each real subject: its article and its Wikidata claims, compared. Only properties still missing are asked.
- Phone (
autotrain.py): acuriousjob after follow-ups, every second question. Feed once a day, grounding, direct ingestion (confirmed facts asautorecords with both sources, plus the lead as "what is X"), lead links (40 a day), then questions through_should_askand every Auto-train gate and cap. Your answered questions, misses and built tutorials are noted. Settingauto_curious(on); Trainer card line "Curious: N topics waiting · K facts kept, D disagreed".ingest.MAX_AUTO2,000 → 10,000, one save per topic (ingest.batch()), Storage row "of which Auto-train facts", and Settings > Clear Auto-train facts (two taps). - Laptop (
curious.py): one worker thread in the backend. - Gates: the
curioussetting (on) and Internet access; 10 minutes since your last question; free memory ≥curious_min_free_mb(800) and swap under 90%; direct ingestion 60/hour and 600/day; questions 20/hour, 200/day, 60 s apart; a rest after network errors. - Direct ingestion: the article through
web_ingest.ingest_url(url, text=, title=)(new parameters: no page fetch), claims viafacts.insert_pending+confirm(confirmed_by="wikidata+wikipedia"); claims the article doesn't state stay pending; contradicted claims are dropped and logged. The FTS index is rebuilt once per batch, at most every 30 minutes. - Questions:
query(QueryRequest(prompt, origin="curious")). The origin runs without the local AI (synthesis.no_llm, carried into the query-expansion thread), logs under sessionaegis-curious(left out of the Handshake query statistics), never speaks the answer, and doesn't count as you asking. Agreeing facts are committed withconfirmed_by="wikidata+web". - Discovery hooks:
ingest_urlnotes every saved page (pages read for your question carry it as their anchor); your answers and misses,/teachWikipedia sources,query_logmisses and pending/reportsare seeds. - State
data/curious.json(atomic);GET /curious/status; one line on the Handshake Training tab. - Backend as a login service:
systemd/aegis-backend.service(user unit,AEGIS_SERVICE=1, Nice 10, idle I/O, MemoryHigh 500M / MemoryMax 800M, restart on failure) and./aegis-service install|uninstall|status|pause CMD|logs. Service mode skips the AI warm-up.run_desktop.shreuses the service,restart.shrestarts it through systemd,build-apk.shpauses it for the build,handoff.shreads the database inside oneaegis-service pause, and the desktop's full Exit stops it through systemd (it starts again at login).
Verified:
mobile/test_curiosity.py42/42 (new): grounding drops made-up subjects, disambiguation pages, lists and years; questions only for properties present; cross-check agree/disagree/no value; frontier thresholds, anchor, depth, kinds in turn, yield rest; feed sample.mobile/test_autotrain.py92/92 (24 new): job order, direct ingestion keeps only confirmed facts (one write per topic), contradicted claims not kept or asked, a disagreeing answer not counted as learned, discoveries from your questions, lead links and facts, caps,auto_curiousoff stops all of it.test_storage45/45 (the Auto-train row). All 25 phone suites pass.test_curious.py58/58 (new, offline, throwaway data folder): every gate, a failed feed fetch retried after the rest instead of skipped for the day, direct ingestion into a real DuckDB (confirmed, pending, contradicted, one FTS rebuild per batch), questions only for what's missing, disagreeing answers not committed, theingest_urlhook and anchor, seeds, theorigintag and query-log exclusion, no local AI in the question or its expansion thread, service mode skips the warm-up.test_ai_guard22/22,test_origin_guard15/15.systemd-analyze --user verifyclean; installed once:install,status(≈118 MB),/health,pause true,/healthagain after 3 s; noLLM warmedin its log.- Not tried on the phone: needs a 0.1.19 build.
2026-10-02 — Phone: saves during the Shield of Zeus hand-back could be lost, saves raced on one .tmp, tests went into the APK — FIXED
Why: found in the 2 Oct health audit.
- Coming back from the background, the app waits up to about 50 s for the service to hand the data files back (
shield.take). During that wait the user can already tap. Trainer saves (starter pack, paste, Use this / Wrong on an auto-learned answer, review commit and skip), keeping or deleting a tutorial, queueing a subject, and the review draft's engine call all wrote straight away. The service could then save over them, or the app could reload the service's files and drop them. autotrain,tutorial_train,market.SnapshotStore,sports.SportsLogandlocal_correctionsbuilt the JSON inside their lock but wrote<name>.<pid>.tmpoutside it. Two threads in one process share that name, so one could rename the other's file away (the same "No such file or directory: ….tmp" as the Sep 28 Tank log).local_correctionshad no lock at all.- The hand-back fingerprint, which decides whether the app reopens its stores, left out
sports_log.jsonandsports_cache.json. The background engine fills both. buildozer.specexcluded only 3 of the 24test_*.pyfiles, so the other 21 shipped in the APK, along withmake-spec-html.py.
Fixed:
main.py: newdefer_until_data(fn, say)and_run_after_data(). While the data is with the service, a save is queued and the user sees "Shield of Zeus is handing back · saving in a moment". The queue runs once the data is back, from_came_backand from a hand-over that fell through. The UI uses it throughui/widgets.after_data.keep_tutorialanddelete_tutorialare held the same way.queue_tutorialanswerswait=True("… try again in a moment") during the hand-back. The three buttons that queue a subject stay enabled.ReviewScreen._askand live sports refresh wait for the data (app.wait_data()), as questions and tutorials already did. A review save that runs after the user has left the screen no longer navigates away.- The five stores write and replace inside their lock.
local_correctionshas an RLock, andloadswaps in the new rows in one step. shield_bg.fingerprintincludes the two sports files.buildozer.specsource.exclude_patterns:test_*.py,make-spec-html.py.
Verified:
test_shield_bg58/58 (20 new checks):- six threads × 40 saves to each of the five stores: no failures, no
.tmpleft behind, whole JSON, all 240 teachings kept; - held saves run once and in order after the hand-back, and one that fails does not stop the rest;
- the trainer, review and tutorial saves and the review draft go through the hold;
- every
test_*.pymatches an exclude pattern. - All 24 phone suites pass (test_themes in the Kivy venv).
- Not tried on the phone: needs a 0.1.18 build. A save made while the service still holds the data now takes effect a few seconds later rather than at once.
2026-10-02 — Laptop backend answered any website: open CORS, no Origin check on writes or the terminal — FIXED
Why: found in the 2 Oct health audit. main.py allowed CORS from *, and nothing checked who was calling. The backend has no login, so any page open in a browser on this machine could call http://127.0.0.1:8000: change settings, ingest pages, delete data, or open the /pty terminal WebSocket (a shell).
Fixed:
config.OWN_ORIGINS:http://127.0.0.1:<port>,http://localhost:<port>andhttp://<HOST>:<port>. CORS allows only those, without credentials.main.py_refuse_foreign_pagesmiddleware: 403 when the request'sOriginis not the app's own. With no Origin, it refuses whenSec-Fetch-Sitesays cross-site or same-site, except for following a link (GET or HEAD navigation). curl, scripts and the desktop app send neither header and are not affected.terminal.pty_endpoint: closes with code 1008 unless the WebSocket's Origin is one of the app's own (HTTP middleware does not see WebSockets).
Verified:
- New
test_origin_guard.py15/15. It calls the app through ASGI directly with a temporaryAEGIS_DATA_DIR, which is removed at the end. It covers the app's own origin, foreign origins (GET and POST), a scripted request with no headers, a followed link, cross-site fetch metadata, the CORS preflight, and the terminal WebSocket from both origins. - Not changed: there is still no login. A program running as this user can call the backend, as before.
2026-10-02 — Laptop: the RAM check and the local-AI setting were skipped on some model loads — FIXED
Why: found in the 2 Oct health audit. Answer synthesis checked config.QUERY_ALLOW_LLM and synthesis.ram_ok() (free memory against AEGIS_LLM_MIN_FREE_MB) before loading the Ollama model. Other paths did not: the boot preload (boot._stage_preload and main._preload_model), query expansion, /teach (one Ollama call with a 600 s timeout), the tutorial chat, the trainer's drafts, the verifier and the device benchmark. Any of them could load the model with swap already full, which is how the 2026-09-25 freeze started (LLM warmed).
Fixed:
synthesis.llm_allowed(model): the AI setting andram_ok. It is checked before every request that can load the model:synthesize(after the cache),stream_synthesize,benchmark_device(falls back to the default rate),query_expand.expand(template fallback),tutorials._ollama_chat,teach(extractive only),trainer._draft_once(no draft),verifier.verify(status "unknown").- The boot preload runs only when the
ai_warmupsetting is on andllm_allowedpasses; otherwise it reports "Warm-up skipped". It now sendskeep_alive.
Verified:
- New
test_ai_guard.py22/22, offline, withrequests.postandram_okpatched. With the AI setting off, and separately with memory short, the boot warm-up, query expansion,/teach,synthesize,stream_synthesize, the trainer draft, the verifier and the benchmark make no Ollama request, and each falls back as listed. When allowed, query expansion still asks the model. - Not covered by the test:
tutorials._ollama_chatandmain._preload_modelwere changed the same way, but only compile-checked.
2026-09-30 — Go deeper levels read the wrong sense, and a rebuild kept levels from the wrong article — FIXED
Why: I read every Go deeper level in the phone's tutorial_library.json (a read-only copy in /tmp/tank-logs-145630). Most levels were accurate. Two were not:
- "How to build a company", Level 2, was four Build-A-Bear blocks (Happy Meal, Build-A-Bearville, Walmart, specialty stores). Go deeper reads the tutorial's own Wikipedia article again, and that was Build-A-Bear Workshop, from before today's how-to object fix. A rebuild would pick "Company" now, but
carry_levelscopied every old level into the rebuilt tutorial, so the Build-A-Bear level would have stayed. - "Securities", Level 3 Examples, used Wikibooks "Basic Computer Security/Introduction".
wikibooks_deepersearched for the bare word "Securities" and threw away the sense the tutorial already had, its article "Security (finance)". A how-to with no article also fell back to a Wikipedia search on the whole phrase ("build company") rather than its object.
Fixed:
tutorial.carry_levels: no levels are carried when the old and new copies are built on different Wikipedia articles (tutorial.main_article:used.article, else the first Wikipedia source). If either copy has no article, the levels are still carried, so a rebuild whose Wikipedia fetch failed does not lose them.tutorial_expand.sense(): the tutorial's build context plus its article's qualifier, less words the topic already has. It is passed towikibooks_deeper(context=), where the book must be on both, and added to the deeper web query.wikipedia_deeper(how=, context=): the fallback search uses the how-to object and the build context, likebuild().build()savescontexton the tutorial, so later levels keep it.
Verified:
- New
test_tutorial_expand.pytest_sense, 11 checks, from the phone's cases: - the fixture Wikibooks book on computer security is taken for "Securities" with no sense, and not taken for Security (finance);
- the Wikibooks search and the web query say "finance";
- "build a company" with no article searches Wikipedia for "company";
- a Build-A-Bear → Company rebuild drops the old Level 2, and a same-article rebuild keeps it.
- The Python deeper fixture's web query is now "python programming language advanced" (the article's qualifier).
- Suites: test_tutorial_expand 66/66 (5 runs), test_tutorial 92/92, test_tutorial_train 77/77, test_tutorial_facts 18/18, test_autotrain 68/68, test_provenance 20/20, test_shield_bg 40/40, test_misses 125/125, test_local_first 12/12.
- Not changed: tutorials already on the phone keep their levels until rebuilt. The Securities Level 3 computer-security block stays even after a rebuild, because its main article is unchanged; deleting that tutorial and building it again removes it. Not tried against the live web from this laptop (this shell cannot reach Wikipedia directly).
2026-09-30 — Tank 3 Pro log (0.1.17, Sep 28): trainer saves collided, harvested subjects lost context, a wrong "how much" answer, "distance between" missed, a game page in a tutorial — FIXED
Why: I read the phone's aegis-log.txt over USB, read-only (copied to /tmp/tank-logs-*). It covers four sessions on Sep 28. Logcat shows no Aegis crash; all the SIGSEGVs come from the vendor fingerprint service (android.hardware.biometrics.fingerprint@2.1). Findings:
tutorial trainer progress not saved: [Errno 2] … tutorial_train.tmp -> tutorial_train.json. The app and the background service each save the same JSON stores through one.tmpname, so one process renamed the other's half-written file away. Overlapping writes could also leave a store that mixes the two.shield_bg.pyalready used a per-process name; the other ten stores did not.- Subjects the trainer harvested from its own tutorials were built with no subject context. From the Security tutorial: "Collateral" became the film, "Market" became Darknet market, "Securities services" became Federal Security Service, "DRS securities" became Department of Intelligence and Security, and "Stockholders" became the artist Jessica Stockholder. Section labels also became subjects ("1830s–1880s: Before celluloid", "Standardisation astronomical tables", "Advantages and disadvantages").
- "how much food is in the united states" was answered from the offline library with "One United States customary cup is equal to 236.5882365 milliliters…". The "how much" check lets one of three key words go missing, and the missing word was "food". With local-first (not yet on the phone), the web would never have been asked.
- "what is the distance between the earth and the sun" found nothing, while "how far is earth from the sun" answered 149.6 million km.
quantity_lookuponly builds keywords for "how far / how many", so the first wording searched Wikipedia as a whole sentence and got Moon and Earth, not Astronomical unit. - "How to build fire" was built from Wikipedia "To Build a Fire" (the story) and crazygames.com "BuildNow GG". The Wikipedia side was already fixed today by the how-to object rule, which now searches for "fire". The game page matched on the words "build" and "fire".
Fixed:
- Ten stores (ingest, market, teach, tutorial, local_corrections, autotrain, sports, training_store, tutorial_train, web_library) write
<name>.<pid>.tmpbeforeos.replace. tutorial_train._harvest: harvested rows carrycontext(the parent tutorial's subject, viatutorial.subject_of), so they get the parent-links filter.generic_headingalso rejects labels that start with a year or decade, "X: Y" labels, and advantages/disadvantages, pros and cons, criticism, limitations and general trend.answer.accepts, quantity branch: for "how much <thing>", the thing must appear in the passage. "how much does/is …" names no thing and is unchanged.qparse._distance: once both places are found, the search is "<a> <b> distance", the same as "how far".tutorial_sources.web_pagesskips sites whose domain contains "game" unless the subject is about games.
Verified:
- Current code, offline, before → after:
- The food question: cup answer → no longer accepted.
- The distance search: the whole sentence → "earth sun distance".
- Two processes saving one store 300 times each: with the shared
.tmpname, 41 and 37 saves failed. With the per-process name, none failed, the JSON stayed whole and no.tmpwas left behind. - New tests:
test_shield_bgtest_two_writers(3 checks);test_tutorial_trainharvest context and labels (2);test_missestest_tank_0928(5);test_tutorialgame-site checks (2).- Suites:
- test_ingestion_part1 97/97, part2 113/113, test_ingest 11/11, test_sources 22/22, test_battery 80/80, test_misses 125/125, test_phone_questions 74/74, test_overview 84/84;
- test_local_first 12/12, test_sports 113/113, test_companies 75/75, test_tutorial 92/92, test_tutorial_train 77/77, test_tutorial_expand 55/55, test_tutorial_facts 18/18, test_autotrain 68/68;
- test_provenance 20/20, test_phrases 139/139, test_common_questions 14/14, test_shield_bg 40/40, test_storage 44/44, test_speech_controls 74/74, test_artemis exit 0, test_themes 120/120 (Kivy venv).
- Not changed: "how far is it from earth to jupiter" still says the sites disagree. That is honest, because the distance varies from 588 to 968 million km, though a range answer would be better. Auto-train learned 0 answers in the logged sessions because it was waiting for the charger (Charging only is on) or had reached "Done for today · 400 questions".
2026-09-30 — Phone web requests ignored their deadline: a 12 s web search took 84 s — FIXED
Why: with the network unreachable, webscan.search raised WebSearchUnavailable after 84.4 s and 84.1 s against a 12 s deadline, and never got past Bing. The cause is urllib.request.urlopen(timeout=…): socket.create_connection tries every address the name resolves to, one after another, and gives each the full timeout. www.bing.com resolves to 14 addresses (5 IPv6, 9 IPv4), and 14 × 6 s = 84 s. The deadline was only checked before the request started. Every engine request had the same flaw: Wikipedia, Wikidata, the topic APIs, Stack Exchange, ESPN and Yahoo. A phone on poor Wi-Fi, or with a dead IPv6 route, could hang the same way. (The laptop's sandboxed shell has only all_proxy set, which urllib ignores, so there every direct connect times out. That is how this reproduced.)
Fixed:
- New
mobile/engine/netopen.py: connect_withinmakes the timeout the budget for all connect attempts together. It tries at most 4 addresses, alternating the resolver's preferred family with the other, so a dead IPv6 route costs at most half the time left.urlopenuses connection classes with that connect.read_withinstops reading a slow body at the deadline and keeps what arrived.- When a test has replaced
urllib.request.urlopen,netopen.urlopendefers to it, so the existing no-network guards still apply. webscan._get(connect and page read),web._open,topic_apis,tutorial_sources.get_json,market.fetchandsports.http_getgo throughnetopen.urlopen.llm.py(local Ollama on localhost) is unchanged.
Verified:
- Same reproduction, before → after:
- "how to bake bread" and "bread preparation": 84.4 s and 84.1 s → 12.0 s each, and DuckDuckGo is now tried after Bing times out.
- Wikipedia
get_jsonwith an 8 s deadline stops at 8.0 s. - Stdlib
socket.create_connectionagainst Bing's 14 addresses, with a 1 s timeout, took 14.0 s.connect_withingives up in under 1.4 s. - New
test_ingestion_part1.pytest_connect_deadline, 6 checks: - address order;
- the preferred address family goes first;
- 14 unreachable addresses give up within 1 s;
- a dead first address leaves time for a live one;
- a slow body stops at the deadline;
- a test's stand-in
urlopenis still used. - Suites:
- test_ingestion_part1 97/97, part2 113/113, test_ingest 11/11, test_sources 22/22, test_battery 80/80, test_misses 120/120, test_phone_questions 74/74, test_overview 84/84;
- test_local_first 12/12, test_sports 113/113, test_companies 75/75, test_tutorial 90/90, test_tutorial_train 75/75, test_tutorial_expand 55/55, test_tutorial_facts 18/18;
- test_autotrain 68/68, test_provenance 20/20, test_phrases 139/139, test_common_questions 14/14, test_shield_bg 37/37, test_themes 120/120 (Kivy venv).
- Name lookup (
getaddrinfo) is still not bounded. It took 41–77 ms here.
2026-09-30 — Tutorials: "how to build a company" was built from Build-A-Bear Workshop; off-topic Stack Exchange threads; concepts lost their subject ("Preparation" → Preparation H) — FIXED
Why: the Tank log showed "teach me how to build a company" filed as a Build-A-Bear Workshop tutorial. There were three causes:
tutorial_sources.wikipediasearched for the whole request "build a company". Wikipedia ranked Build-A-Bear second, and its lead mentions "company", so the pick accepted it.- Stack Exchange searches without a tag sorted by votes and counted a question as on topic when the words appeared anywhere in its body. For "bake bread" this gave Seasoned Advice threads about soup and knives.
- A concept link inside a tutorial (for example "Preparation" in the bread tutorial) was queued as a bare word.
web.title_matchesdrops one-letter words, so "Preparation H" counted as "Preparation".
Fixed:
mobile/engine/tutorial_sources.py:- how-to requests search Wikipedia for the thing made (
teach._object: "build a company" → "company"). - With a context subject, a concept is only taken from articles that the subject's article links to (
_links_of, Wikipediaprop=links). A lead-mention check was tried and dropped, because the Leavening agent lead never says "bread". - Wikibooks and the web query include the context. A web query ending in "tutorial" returned nothing, so that suffix was dropped.
- Stack Exchange searches without a tag sort by relevance and only count the title and tags as on topic.
mobile/engine/web.pytitle_matches: a one-letter word in the candidate title must also be in the wanted title (so Preparation H and Vitamin C no longer match "Preparation" and "Vitamin").mobile/engine/tutorial.py:build(context=)andsubject_of().mobile/engine/tutorial_train.py:request(context=)keeps the context with the queued subject and passes it to the build.mobile/ui/tutorial.pyandmain.py: a concept link queues with its tutorial's subject.
Verified:
- Desktop app, live network:
- "build a company" → Wikipedia: Company.
- "bake bread" → Bread, with six on-topic Seasoned Advice questions.
- "change a tire" → Tire.
- Concept queue: Leavening (bread) → Leavening agent; Preparation (bread) → Meal preparation.
- New offline tests, using fakes for Wikipedia and Stack Exchange:
test_tutorial.pytest_accuracy(12 checks) andtest_tutorial_train.pytest_context(3 checks). - Suites: test_tutorial 90/90, test_tutorial_train 75/75, test_tutorial_expand 55/55, test_tutorial_facts 18/18, test_misses 120/120, test_phone_questions 74/74, test_overview 84/84, test_battery 80/80, test_phrases 139/139, test_local_first 12/12, test_companies 75/75, test_sports 113/113.
- Was still open at the time:
webscan.searchraised WebSearchUnavailable after 84.6 s against a 12 s deadline, and tutorials came out thin when the web search failed. Fixed in the entry above (netopen.py).
2026-09-27 — Local first: the phone checks its offline library before the web; the laptop skips web expansion for short questions it can answer — CHANGED
Why: the phone asked the web before its offline library (2,091 pack documents). Web answers took about 3–8 s and library answers about 60 ms, and the home screen already said "offline library … then a web search when online". Git, BUGS.md and past sessions give no deliberate reason for the order. The library was the last step of the offline-only engine (it also built the NOT FOUND reply), and 0.1.12 put the web in front of it. There is a real reason for some intents, though. With the web off, the library was wrong on 5 of 7 "define" questions (a sentence that only mentions the word), gave a WHO health-worker sentence for "how many people are in the world", missed "how old is the earth", and answered "who invented the airplane" with a headline. It is also dated: 95 moons of Jupiter against the web's 115. On the laptop, _needs_fresh_info flags every question of four words or fewer as "short question", so those always went to DDGS/Jina even with full local coverage; only definitions skipped the web.
Changed:
mobile/engine/answer.py:_lookuporder is now definitions → stored answers → saved tutorials → saved web pages → offline library → web → trimmed-definition fallback → miss._from_libraryreturns the library passage only when it passesacceptsand was not marked wrong. A number, date or count from it carries SINGLE_SOURCE and is never verified._WEB_FIRST(define, who, age, population, holder, superlative, count, overview) keeps these intents web-first, with the library as the fallback after the web. Overview is included because its answer is the article lead plus key facts;test_phone_questions5 caught a one-sentence library answer.- Live questions still skip everything saved.
- Trace: a
localline lists the sources checked before going online (and says when the library comes after the web).replaynow also names "definitions" and "offline library". local_first.py(new, laptop):local_answer_sufficeslets a "short question" skip web expansion when coverage is ≥ 90% and the top local sentence frombuild_answerholds every query word plus a date, number or name when the question asks for one.build_answer's own score bar is word overlap only: "The WW2 museum has an end-of-year sale." passes it for "when did ww2 end".main.py/query: the gate runs after the definition skip and also requires_subject_present. The response shape is unchanged, and the forced web search still runs if extraction then finds nothing.- New test
mobile/test_local_first.py;test_aegis.py§3b; HANDOFF.md §5 and §15; thehandoff.shARCH note.
Verified:
- Phone, web on, fresh store per question, warm engine, before → after:
- penicillin 2649 → 86 ms (17 → 0 requests); water formula 4592 → 64 ms; seasons 2288 → 57 ms; electricity 2753 → 79 ms; internet 3018 → 23 ms; moon landing 8416 → 67 ms.
- "who is albert einstein" 3651 → 416 ms. Its one request is the sports stage's athlete check, which runs before
_lookup. - Jupiter moons (count, web-first) is unchanged at about 2.6 s and still says 115. Web off is the same before and after (23–95 ms).
- Every library answer in a local-first intent was right (battery and probes, 7 of 7). All wrong library answers were in
_WEB_FIRSTintents. test_local_first.py12/12:- a passing library answer makes no web call and no
urlopen; - a failing one lists the local sources, then calls the web;
- a live question goes to live sources with library text present;
- a library answer marked wrong is not replayed;
- a count asks the web first, and the library answers with the web off;
- a miss says "Searched this phone, then …".
- All 24 phone suites pass.
test_aegis73/83: the 7 new checks pass; the 10 failures are baseline (backend not running, old log errors). The backend was not started and DuckDB was not opened.
2026-09-27 — Carrier phrases: one catalogue of question wordings for the phone and the laptop — ADDED
Why: each new wording ("fill me in on X", "do you know who X was", "X vs Y", "which ocean is the largest") needed its own hand-written pattern, and many fell through to the generic lookup. The laptop had its own separate patterns.
Added:
mobile/assets/intent_phrases.json: 1,286 phrases with{x}(and{y}for comparisons), each read as a question form the parser already handles.- Categories and counts: overview 136, define 97, who 82, where 107, when 98, why 99, how 97, list 98, superlative 93, compare 58, count 40, status 63. Indirect questions (117) and declarative requests (101) each map to their underlying intent.
- Wrappers: 96 prefixes and 34 suffixes, peeled off in any number.
- Reserved phrases (tutorials, about-app, greetings, the clock) are never rewritten.
- The json extension and
assets/*were already packaged by buildozer. mobile/engine/phrases.py(stdlib only):- Compiles the catalogue once. Longest phrase is tried first; matching is anchored and ignores case, apostrophes and trailing punctuation.
- Returns the canonical question, intent and subject. Meaning questions about "the/a X" read as "what is X".
- Guards: pronouns, wh-clauses, arithmetic and digits are never slots. A superlative slot must start with a superlative word.
problems()validates the catalogue: duplicates, slots, targets.- Phone:
qparse.parseruns_carry(raw)before its patterns. Tutorials (teach.detect) and live data are left alone; any error falls back to the raw question. company_askandsports_asktake only overview rewrites.Engine._answeralso looks up pack corrections under the canonical question, so "which ocean is the largest" gets the stored "Pacific" answer.- Laptop:
intent_phrases.pyloads the samephrases.pyby path (one source). - In
main.py, the innermost@_carrier_phraseon/queryrewritesreq.promptfor the call and restores the typed prompt afterwards, so the query log keeps it. - It is skipped when a sense is chosen, when a saved correction exists for the typed prompt, for teach commands, and for digits/stocks/weather/time/currency.
- "Where does X come from" keeps its own wording: "where is X from" would lose "from" to
_DANGLING.
Verified: mobile/test_phrases.py 139/139. It fills every phrase with 3 subjects and checks category and subject, the parser intent, that the canonical maps to itself, and the re-parse. It also covers wrapper stacks, 44 guard cases, the satellite questions, and the pack answer. All 23 phone suites pass. test_aegis 66/76: the 14 new checks pass; the 10 failures are baseline (backend not running, old log errors). Live: "hey artemis do you know what the first satellite put in orbit was" gives Sputnik 1 and "fill me in on black holes" gives the Black hole lead. "cats vs dogs" is still no_answer (it was before too; the difference route needs singular subjects).
2026-09-27 — Phone: "the first satellite put in orbit" found no answer — FIXED
Why: on 0.1.17, "tell me about the first satellite put in orbit" and "what was the first satellite put in orbit" gave no_answer. The superlative parser needed a -est word or first/last + noun with a narrow shape. The overview parser took the "tell me about" form whole. The answer checks refused Sputnik's lead because "put in orbit" is in its second sentence.
Fixed:
qparse._superlative: first/last/-est + a short noun + a qualifying clause is a superlative for what/which/who. The clause can be a participle ("put in orbit", "ever built"), "to <verb>", "that/which/who…", or "in <place>". The clause goes todetailand the noun stays short ("satellite").- "what is the First Amendment", "the first law of thermodynamics", "the Last Supper" and capitalised titles stay define/about.
qparse._overview: a described record inside an overview or about request ("tell me about the largest ocean", "explain the tallest building in the world", "what do you know about the first man on the moon") becomes the superlative question. "tell me about the Great Wall" and "tell me about Apple" stay overview.checks.superlative_ok:- The clause's content words must appear in the first sentences, with stems ("put in orbit" ≈ "launched into orbit").
- Neutral ordinal words ("artificial", "recorded") don't count as narrowing.
- A proper-noun owner ("launched by the United States") is refused as qualified.
- Inverted sentences accept participle clauses ("The first artificial satellite launched into the Earth's orbit was … Sputnik 1").
answer.sentence_from_pages: for a clause superlative, a lead followed by an "It/This/They…" sentence is checked as a pair.
Verified: test_misses 120/120 (new test_first_records: both questions, Sputnik's two sentences accepted and the lead alone refused, Explorer 1 and Aryabhata refused, Armstrong accepted and Gagarin refused, guard parses). Live (temp library): both questions give "Sputnik 1 was the first artificial satellite to orbit Earth." (sovietspaceprogram.com, Wikipedia: Satellite).
2026-09-27 — Phone: style change never reopened the app; background service failed from threads — FIXED
Why: on the Tank 3 Pro (0.1.17, Android 15, targetSdk 35), picking a style closed Aegis but it never reopened; the user had to open it by hand (app log: "restarting for the new style" at 17:17:01, next start 17:17:13). The alarm-based reopen fires after the process is gone, and Android 10+ blocks an app that is not on screen from starting an activity (the API 34 creator opt-in only helps while the app is visible). The same log showed Shield of Zeus background service not started/stopped: ClassNotFoundException: online.mindflux.aegis.ServiceShield every time it was called from the restart handoff thread or the startup thread: jnius in a non-main thread uses the system class loader, which cannot see the app's classes. From the main thread (on_pause) it worked.
Fixed:
mobile/engine/restart.py: no alarm. On "Restart now", on the main thread, while still visible: post a "Aegis is restarting · Tap to open Aegis with the new style" notification (fallback; a notification tap may always open the app), callstartActivity(Intent.makeRestartActivityTask(launch component)), then end the process after 0.1 s so Android starts the new task in a fresh process.clear_notice()removes the notification at the next start.mobile/main.py:_resolve_service()looks upServiceShieldand the activity once in_build(main thread);_service()reuses them from any thread. Startup callsrestart.clear_notice.- Dialog text: "It closes and reopens by itself; if it doesn't, tap the "Aegis is restarting" notification or open it again."
Verified: test_themes 120/120 (relaunch order notice → new task → exit, failures still exit, no AlarmManager, service lookup on the main thread, notice cleared at start); test_shield_bg 37/37, test_speech_controls 74/74, test_storage 44/44, test_tutorial 77/77. Needs the next APK on the phone to confirm the reopen.
2026-09-27 — Phone: selectable styles (7 themes), restart to apply, honest boot progress — ADDED
Why: the user wants the six mockup styles (mobile/design/themes/), plus the current look, as choices in Settings. A new style should show only after the app restarts: the app says it must close, closes, and reopens in the new style. The boot bar was fake (six 0.42 s timers).
Added:
- Styles.
mobile/engine/themes.pyholds the seven styles: Classic (default, the look before this change), Obsidian Pro, Parchment Desk, Olympus Night, Aurora Glass, Daylight Clean and Signal Terminal. - Each has colours, radii, flags (gradient actions, Greek-key rule, capital eyebrows, bold headings), fonts, a wordmark and a boot layout.
themeis inDEFAULTS. An unknown value falls back to Classic.- Classic is kept. It is darker navy with silver marks, narrow type, pill buttons and a top sheen line, so it is not the same as Obsidian (warm near-black, hairlines, one ember accent, spaced serif wordmark, 10 dp corners).
- Applied once per run.
- The saved style is activated before any KV loads.
Window.clearcolorcomes from it. - Every KV string gets that style's
#:setconstants (themes.kv_header).theme.kv's own 24#:setlines are gone. - Python colours read
themes.Cat use time. That covers cards, buttons, fields, top bars, bubbles, chips, the tutorial reader, Shield cards, the speech bar, storage rows, modals, the logs panel, screen backdrops and the Artemis dot. - Picker. It is the first card in Settings (STYLE). Each row has swatches (bg, surface, accent, text), the name, one line of description, and IN USE on the current style.
- Tapping another style opens a dialog in the current style: "Switch to <name>? Aegis needs to close and restart to apply the new style. It closes and reopens by itself in a few seconds; if it doesn't, open it again." with Cancel and Restart now.
- The palette never changes before the restart.
- Restart (
mobile/engine/restart.py, with the platform part isolated): - The setting is saved with fsync, and "Restarting…" shows.
- Speech is stopped and cleared, Artemis is disabled, and device polling stops.
- Shield of Zeus is handed to its service with the existing
Handoff.give()(the service is not killed). Without the service, the trainer stops (≤ 30 s) andshield_bg.save_all()runs. - Android: an inexact AlarmManager alarm 500 ms ahead reopens the launch activity, via a PendingIntent with
FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE,NEW_TASK | CLEAR_TASK, and the background-start opt-in on API 34+. ThenfinishAffinity()andos._exit(0)run. There is no new permission, and the service's separate process doesn't block the exit. - Fallback: the app closes, and the style applies next time it is opened.
- Desktop:
App.stop(), thenos.execvof the same command. - Honest boot. Four real steps with weights: take the data back from Shield of Zeus (10), saved knowledge (45), the library (35), start (10).
shield_bg.open_stores(progress=…)reports saved and library with counts. A failure marks its stepfail.- Minimum 0.9 s on screen, and "Ready" holds 0.3 s.
- There is one boot layout per style: Classic bar, Obsidian step list, Parchment skeleton, Olympus ring and shield, Aurora orb and glass card, Daylight checklist, Terminal
[ ok ]lines with a blinking cursor. - Fonts. Liberation Sans Regular (410,820 B) and Bold (414,568 B), SIL OFL 1.1, copied from the system
fonts-liberationpackage. The licence is atassets/fonts/LiberationSans-OFL.txt, andtxtwas added tosource.include_exts. The version stays 0.1.17. - Contrast fixes.
- Daylight bubble
#2F6BFFbecame#2A62F0(white text now 5.1:1). - Aurora's on-accent text is dark (5.8:1).
- Olympus bubble text is ink on the translucent gold (13.4:1). It had fallen back to dark text at 1.56:1, and
test_themescaught it. - The startup-failure text uses the style's ink.
Verified:
test_themes.pypasses 107/107 (root venv) and 114/114 (Kivy venv, which also loads every KV string under each style). It covers every token and RGBA; contrast (text ≥ 4.5 on BG/PANEL/PANEL_HI/FIELD_BG, bubble ≥ 4.5, accent buttons ≥ 3); the fallback; picking not changing the palette; confirm saving then restarting; cancel changing nothing; startup applying the saved style to the boot screen; and both relaunch branches with faked platform calls.- Also passing:
test_tutorial77/77,test_tutorial_expand55/55,test_speech_controls74/74,test_storage44/44,test_phone_questions74/74,test_autotrain68/68,test_tutorial_train72/72,test_shield_bg37/37,test_artemisOK, andtest_sports113/113 (its KV check now reads the style header). - Desktop:
desktop_shots.py --themessaved boot, Ask, a tutorial and Settings for all 7 styles to/tmp/aegis-themes/. It also saved Classic → Olympus: the confirm dialog, "Restarting…", and the re-executed process's first frame, boot and first screen. The new process loggedstyle olympus, saved olympus, clear colour (0.039, 0.055, 0.11, 1.0). - Not verified on the phone: the Android relaunch (alarm plus background-start rules), until the next APK.
2026-09-27 — Phone: Shield of Zeus keeps building queued tutorials in the background — ADDED/FIXED
Why: the user pressed "Build a tutorial on this" about 24 times (Tank 3 Pro, 0.1.16, logs pulled read-only on 2026-09-27). The button said "Queued in Shield of Zeus", but only 3 were built in 8 minutes, exactly 3 minutes apart, and none after the app went to the background or was swiped away. The user wants Shield of Zeus always running in the background unless it is turned off, processing whatever the buttons queue.
What the phone's logs showed:
- User subjects were held by the self-training limits. The 3-minute gap and the 10-a-session / 30-a-day caps applied to them. The log showed
built "Reference frame" 15:58:35,"The Fall of the Moon" 16:01:39,"Second law" 16:04:43, with 22 subjects still infront. - Newest first:
add()put new keys at the front. The first subject queued (Perturbation theory, 15:56:42) was never reached. - Re-tap re-queued: tapping the button for a subject already built put it back in the queue and reset its refresh (
fram referencwas built but still infrontwithnext_at 0.0). - Generic headings got the button and were harvested as subjects: "Key Ideas", "Definition", "Introduction", "Instances", "Corollaries", "Life and career".
- Training stopped in the background:
Paused · app in the backgroundat 15:19, 15:24 and 16:02. REMOVE TASK at 16:05:03 killed the process, and the thread with it. - Go deeper kept offering a level it had just failed to find: "nothing new" three times in 45 s (15:58:50, 15:59:12, 15:59:32).
Added:
- Background service. A python-for-android foreground service runs in its own process
:service_shield, declared inbuildozer.specasservices = shield:shield_service.py:foreground:sticky:foregroundServiceType=dataSync. New permissions:FOREGROUND_SERVICE,FOREGROUND_SERVICE_DATA_SYNC,POST_NOTIFICATIONSandWAKE_LOCK. - Sticky, so it keeps running after a swipe-away.
- Shows a "Shield of Zeus is learning" notification with the live status; tapping it opens Aegis.
- Holds a partial wake lock only while there is work it may do.
- Stops itself when Auto-train is turned off, when the app comes back, or after 5.75 h. Android 15 gives data-sync services about 6 h a day, and the allowance starts over when the app is opened.
- One writer at a time (
mobile/engine/shield_bg.py): - The app and the service share the JSON files. Whoever works holds an exclusive
flockonshield.lock, which the OS drops if the process dies. Handoff.give()runs on pause. It stops the app's trainer, waits (up to 90 s) for the job in progress and for the user's own question or tutorial, saves, and releases the lock.Handoff.take()runs at start and on resume. It writesshield.stop; the service finishes its job, saves, writes a summary toshield_service.jsonand lets go. If the service hasn't let go after 40 s, the app stops it.- The app reloads its stores only if the files changed (fingerprint), and shows the service's
[bg]log lines in its Logs panel. - Asks, tutorial builds and Go deeper started during a handover wait for it (
app.wait_data()). ServiceLoopis the service's side.open_stores/make_trainerare shared by both processes.- User queue (
tutorial_train.py): request()queues in the order given and returns queued / have / skipped.next_job(user_only=True)returns the user's subjects first.- They are 25 s apart (
USER_GAP_S) and not held by the 3-minute gap, the session cap or the day cap. Self-chosen subjects still are. User builds don't count toward the caps. queue_view()gives each queued subject a state and a reason: - Building now / Next / Next · in N s / Waiting · 2nd in line; - Waiting for a network / Wi-Fi, Paused, Resting, Waiting · you are asking; - Waiting · Auto-train is off / Auto-build tutorials is off; - Stack Exchange allowance used up until HH:MM; - Not found yet · tries again at HH:MM; Built / Short (opens on tap); Not found.- 0.1.16 state files are migrated to first-in-first-out.
- Auto-train (
autotrain.py): gate(user=True): the user's tutorials don't wait for the charger or the question caps. The network, Wi-Fi, rest, busy, Off and Search-the-web gates still apply.tick()builds a due user subject before anything else. When one is waiting behind the gap, it wakes afterUSER_GAP_Sinstead of 60 s.- New helpers:
user_blocked(),tutorial_queue(),stop(wait),running(). - UI:
- The "Build a tutorial on this" button (reader and Ask misses) now follows its subject: "Queued in Shield of Zeus · next, in 12 s" / "· waiting for a network" / "Shield of Zeus is building it now" / "Built · tap to open". The same button then opens it. A subject already built says "Already built · tap to open".
- TUTORIALS card: a "Your queue" list, one row per subject with its state and why it waits; built rows open on tap.
- AUTO-TRAIN card: a "Background:" line (working now, or the last run: learned N, built M, K still waiting, why it stopped).
- Setting descriptions say it keeps going in the background until turned off.
- The app asks for the notification permission on Android 13+ when Auto-train is on.
Fixed:
- Re-tapping a built subject no longer re-queues it; the tap opens it.
- Generic headings are no longer harvested or offered as "Build a tutorial on this".
- Go deeper stays off for 30 min after "nothing new" ("Nothing new found just now · try again later").
app_version.pyreads the version in the service process too (PythonService.mService). "unknown" would have re-armed every miss.apploggetsset_origin("[bg] ")andabsorb().- The version is now 0.1.17.
Not fixed (out of scope):
- Queued concept titles lose their parent's context: "Second law" became the second law of thermodynamics while the user was reading a Force tutorial, and "The Fall of the Moon" read a Wikipedia page on Maleah Joi Moon.
- Titles drop stopwords ("Fall Moon tutorial").
- Two native SIGSEGVs in pyjnius callbacks on the main thread (2026-09-26 17:35 in
kivy/graphics/instructions.so; 2026-09-27 14:37 inPyObject_ClearWeakRefs). - The app's memory reached 1.3 GB RSS before the swipe.
- A system fingerprint HAL crash loop every 5 s floods logcat. That one isn't Aegis.
Verified:
test_tutorial_train72/72 (new: user subjects picked at once and not held by the gap or caps,USER_GAP_S, self-chosen subjects still capped, FIFO, per-item reasons, re-tap, generic headings, migration, Auto-train building them without the charger and past the caps).test_autotrain68/68 (user gate,waits_on(user),stop(wait)joins the thread).- New
test_shield_bg37/37: - lock exclusivity;
- app → service → app turns with a real AutoTrainer, with the service building queued subjects while the app is away;
- no overlapping writers;
- changed-file detection and log lines;
- the service stopping on Auto-train off, the stop file and the daily limit;
- give/take edge cases;
- service defaults matching
main.pyDEFAULTS. test_tutorial77/77,test_tutorial_expand55/55,test_tutorial_facts18/18,test_phone_questions74/74,test_speech_controls74/74,test_storage44/44,test_artemisok,test_misses92/92,test_provenance20/20,test_battery80/80.buildozer.specparses; py_compile passes on every changed file.shield_service.py --data <tmp>with Auto-train off starts and stops cleanly.- Desktop run with a temporary data folder:
/tmp/soz-tutorials-queue0001.pngand/tmp/soz-tutorials-queue-offline0001.png. - Not built or installed yet: check the notification, swipe-away and the handover on the phone.
2026-09-27 — Phone: Go deeper, a new level on any saved tutorial — ADDED
Why: the user wants to keep expanding a tutorial ("There's always gotta be another level as well, right?").
Added:
mobile/engine/tutorial_expand.py(new):expand(tutorial, settings, library, fetch, progress, by)adds the next level (Level 2, 3, …; no ceiling). Each level reads the sources again for material the tutorial doesn't have:tutorial_sources.wikipedia_deeper: the tutorial's own article in full, for sections and subsections not used yet (h3 at level 2, h4 from level 3), plus up to 2 linked sub-articles on a narrower part of the subject. History, list, "comparison of" and foundation pages are skipped, and so is anything off-topic for a tech subject.wikibooks_deeper: the next 3 unread chapters, in the book's order.stack_exchange(page=, skip_ids=, seen=): the next page of top-voted questions for the tag, skipping question ids the tutorial has seen.web_pages(query=, skip=): a deeper search ("… advanced", "… examples", "… in depth", …; how-to phrasings for how-tos), skipping pages already used.- The level is put together by build's own code, so the same honesty rules apply: every block names its source and nothing is written that a source doesn't say.
- Sections are Key concepts (sub-article leads count as concepts), Steps (how-to only), Worked examples and Common problems and fixes.
- They are titled "Level N · …" with ids
level{N}-…, placed before Further reading. New sources get Further reading rows. - Headings, sentences and code already in any level are never repeated.
- When nothing new turns up, nothing is added. The result says "No more material found for a deeper level right now", and
deeperrecords the try, so the GO DEEPER card shows it and the user can try again later. - Stored with the tutorial in
tutorial_library.json, so levels open offline: level;levels[](time, by user or auto, section ids, blocks, new sources);used(URLs read or tried, Stack Exchange ids and page, the Wikipedia article);deeper.- Limits: 20 blocks per level. A tutorial over 400 KB of JSON gets no more levels and says so.
tutorial.py:_Refs(rows)can continue an existing source list;_concepts(seen=, deep=),link_block(),level_of();concept_titles()includes level concepts (search, Related, entries);entries()reportlevel;carry_levels()inTutorialLibrary.save, so a rebuild (the user's or the trainer's) keeps the levels, with sources renumbered and marks kept.- Origin: going deeper as the user makes a Shield of Zeus tutorial the user's, the same as Keep. The trainer's own expansions leave it auto-built.
- Reader (
ui/tutorial.py): - a "Go deeper · build Level N" button in the header;
- a GO DEEPER card at the end, with what the next level reads, the last "no more" try, a web-off note, the button and live progress ("Level 4 · 12 s / Stack Overflow: 1 answered question");
- it runs on a thread, and the reader re-opens at the new level;
- the Contents row gets "L2 Concepts", "L3 Problems", "Go deeper";
- the header and the Tutorials list show "Level N".
main.tutorial_busy()includes the reader'sexpandingflag, so Auto-train pauses.- Shield of Zeus (
tutorial_train.py): a tutorial the user took deeper before gets one more level by itself once a week (DEEPER_EVERY_S). - This comes after due rebuilds and before link subjects, under the same gates and gap/session/day caps (it counts toward the 30 a day), and never while Stack Exchange is resting.
- The TUTORIALS card lists "Deeper" / "No more found".
- The facts index needed no change. Level blocks are concepts, Stack Exchange answers and passages like any others, and "That's wrong" marks a level block by its section id and index.
buildozer.specset to 0.1.16 (it said 0.1.17 after two bumps). Everything since the 0.1.15 APK ships as 0.1.16: the storage figure, voice pause/stop, the tutorial trainer and Go deeper.
Verified:
test_tutorial_expand.py(new): 55/55 on a fake network (thetest_tutorial.pyfixtures plus Wikipedia links and sub-articles, 5 Wikibooks chapters, Stack Overflow pages 2 and 3, and a deeper web search). It covers:- Level 2 adds only unused material (no sentence, code or question from level 1), every block has a valid source, it names an h3 subsection and a sub-article, uses page 2, reads the next chapters in order, and invents nothing;
- Level 3 goes deeper again (an h4 subsection, the next sub-article, a later chapter, page 3) without re-fetching pages;
- Level 4 finds nothing, adds nothing, says so, and records the try;
- levels reload from the file, and section ids are unique;
- the facts index answers from a level concept with its own source, and rejecting a level block works;
- a rebuild keeps the levels and their sources;
- origin (user vs auto), web off, teach-me articles, the size cap, the busy flag;
- the trainer's weekly expansion counting toward the day.
- Suites: tutorial 77/77, tutorial_train 43/43, tutorial_facts 18/18, phone_questions 74/74, autotrain 59/59. misses 89/92: only the 3 known year-split checks, since
mobile/engine/extractive.pygets the restored fix at the next build. - Desktop run (
aegis-mobile/.venv, temporary data folder, fake network for the press): a Level 3 Python tutorial in the reader, then Go deeper showing "Building Level 4…" with live progress, then the "No more material" note. No errors in the log.
Backup: .aegis-doctor/backups/tutorial-expand-20260927-154340/
2026-09-27 — Year fix restored in the laptop extractive_qa.py — FIXED
Why: the 0.1.15 fix for years cut from the end of sentences ("released in 1995." became "released in.") was made only in mobile/engine/extractive.py. mobile/build-apk.sh copies the laptop's extractive_qa.py over that file on every build, so the fix was lost: mobile/test_misses.py dropped to 89/92 and today's APK does not have it. The laptop engine had the same bug.
Fixed:
extractive_qa.pyline 25 (the only change):_NUMBERED_ITEM_REwent from\s+(?=\d+\.\s+[A-Z])to(?<=[.!?:;])\s+(?=\d{1,2}\.\s+[A-Z]), the same regex as the 0.1.15 phone fix. A list item must be 1–2 digits and follow sentence punctuation.test_aegis.pysection 3: two new checks, "split keeps a year that ends a sentence" and "split still breaks a real numbered list".mobile/engine/extractive.pywas not edited; the nextbuild-apk.shrun copies the fix in.- Backup:
.aegis-doctor/backups/extractive-yearfix-20260927-154257/.
Verified: py_compile is clean. The new year check fails with the old regex ("released in.") and passes with the new one. test_aegis.py: all section 1–6 checks pass, including both new ones; the 10 failures are the live-API section (backend not running) and the log's "no recent errors". test_accuracy.py 26/26, test_extractors.py 31/31, test_disambiguate.py all passed. In a temp copy of mobile/ with the laptop files copied in the way build-apk.sh does: test_misses.py 92/92, test_phone_questions.py 74/74.
2026-09-27 — Phone: Shield of Zeus builds tutorials by itself, Tutorials navigation, answers from saved tutorials — ADDED
Why: the user wants the phone to make its own list of tutorials and "teach me" articles and gather the material for them, to move between the tutorials already saved, and to have the query engine answer from what the tutorials say.
Added:
- Tutorial trainer,
mobile/engine/tutorial_train.py(TutorialTrainer, state intutorial_train.json): - Subjects in this order:
- subjects the user adds (Shield of Zeus "Add subjects", the reader's "Build a tutorial on this", a missed how-to's offer);
- the user's own activity: tutorial requests that found nothing or came out short, and how-to and programming questions from Auto-train's history;
- tutorials due again;
- Key-concept titles, Stack Exchange question titles and Wikipedia/Wikibooks Further-reading pages found in built tutorials (one level deep, filtered with
trainable); - the seed list.
- Seed list:
mobile/assets/tutorial_seeds.json(772 subjects), made bymobile/build_tutorial_seeds.py(not shipped) from the question seeds' tech, science, math, concept, language, health and howto kinds plus 73 programming subjects.TutorialSeedsgoes through it one area at a time (programming, how-to, science and math, other), resuming from a cursor. - Each subject is built once with
tutorial.build(Search the web on, Local AI off). The short article comes from the same material through the newteach.from_tutorial()and is filed through the ingester as kind "tutorial". Nothing is fetched twice. - Honesty: a subject no source covers is not saved; it is marked failed, retried after 6 h and 24 h, then dropped. A short tutorial is kept with its label and rebuilt after 7 days. Auto-built tutorials are refreshed after 30 days. The user's own complete tutorials are never replaced.
- It runs as a "tutorial" job in
AutoTrainer._next_job, behind every Auto-train gate (including busy while the user asks or builds). Its own limits: 3 minutes between tutorials, 10 a session, 30 a day, and no Stack Exchange subject while the allowance is used up (polite.busy_for(SE_API)). A new setting,auto_tutorials, is on by default. - Library (
tutorial.py): - an
originfield (user/auto) and anareafield; - separate caps of 60 user and 150 auto-built tutorials, oldest auto-built removed first;
keep(),related(),reject_block(), and aversioncounter (also onTutorialStore);group(),matches(),article_entries()andfrom_article()for the page.main.py:queue_tutorial(),keep_tutorial(), anddelete_tutorial(), which bans the subject of a deleted auto-built tutorial.- Shield of Zeus TUTORIALS card (
ui/trainer.py): the Auto-build tutorials switch, a status line, counts (built today, auto-built kept, short, not found, teach-me articles), Recently built rows that open the reader, and the Add subjects field. - Tutorials page (
ui/tutorial.py): - search over titles, subjects and Key-concept titles;
- groups: Programming, Science and math, How-to, Other;
- a "built by Shield of Zeus" marker;
- a Teach me articles group, opened in the reader through
from_article. - Reader:
- a Contents row that jumps to each section;
- Previous and Next through the page's list;
- a Related tutorials card;
- Key concepts that open another saved tutorial, or offer "Build a tutorial on this";
- Keep on auto-built tutorials.
- Query engine:
mobile/engine/tutorial_facts.pyTutorialIndexholds definitions, concepts, how-to steps, Stack Exchange question → answer, and a BM25 passage index (web_library's_Index). It is rebuilt when either store's version changes.answer._lookuphas a new "Saved tutorials" step after stored answers and before saved web pages (offline; skipped for live questions). It returns qtypetutorial_saved, stage "From a saved tutorial", and cites the tutorial's original sources.- Checks: definitions, concepts and passages must pass
_saved_ok. How-to step lists and Stack Exchange answers can't be read by the article-lead checks, so they pass teach's replay check instead (every step is an instruction; the answer names the action asked about). - A number, date or count is marked verified only when two sites in the tutorial state it. Otherwise it carries the single-source note.
- "That's wrong" goes through
ingest.rejectand marks the block rejected in the tutorial. - A miss offers "There's a saved tutorial on X" or, for a how-to, a button that queues "Build a tutorial on X" in Shield of Zeus (
ui/engine_page.py). provenance.pydescribes the new answers.tutorial_savedis inKNOWN_QTYPES.
Verified:
test_tutorial_train.py(new): 43/43. It covers:- subject order and dedupe;
- both formats built from one fetch;
- link harvesting one level deep;
- made-up subjects not saved, then dropped;
- short and 30-day rebuilds;
- the session, day and gap caps, the setting, and the Auto-train gates;
- the Stack Exchange quota back-off;
- the user/auto caps and removal order;
- Keep, and banning on delete.
test_tutorial_facts.py(new): 18/18. It covers:- "what is css", "what is a css selector", "how do i work in the terminal on ubuntu" and "how do I reverse a list in python" answered offline with their original sources;
- a single-site year said with the single-source note, and verified once a second site agrees;
- "that's wrong" excluding the block;
- a missed how-to offering a build;
- five existing answers unchanged.
test_tutorial.pynow passes 77/77 (added: search, groups, related ranking, the article adapter).- Other offline suites: phone_questions 74, artemis ok, companies 75, autotrain 59, provenance 20, ingestion 91 + 113, overview 84, sources 22, ingest 11, sports 113, common_questions 14, battery 80, storage 44, speech_controls 74.
test_missesis 89/92. The three failures are theextractive.pyyear-split checks:build-apk.shcopies the laptop'sextractive_qa.pyovermobile/engine/extractive.pyon every build, and the laptop copy had lost the 0.1.15 one-line year fix. Not changed here (never-edit file). The laptop copy has since been fixed (entry above), and the next build copies it in.- Desktop run (
aegis-mobile/.venv, temporary data folder, Search the web off) with screenshots of the TUTORIALS card, the grouped page, search, the reader's Contents / Previous / Next / Keep, Related tutorials, concept links, "Build a tutorial on this" and a teach-me article. No errors in the log. - Ships in 0.1.16, one unreleased batch with the storage figure and the voice controls (a 0.1.15 APK was already built). The phone was attached, but adb reported "no permissions", so the installed version was not read.
2026-09-27 — Phone: pause, resume, stop and play for answers read aloud — ADDED
Why: the user wants to pause or stop the voice reading of responses at any time, resume a paused reading, and have Play after Stop start the same reading again.
Added:
mobile/engine/speech.py:SpeechGatereads a text a sentence at a time (chunks(): long sentences split at a comma or space, very short ones joined to the next). Android's TextToSpeech has no pause, so:pause()cuts the voice and keeps the sentence;resume()reads again from the start of that sentence;stop()goes back to the top, andplay()then reads the same text from the beginning (after a reading ends,play()also reads it again);clear()forgets the text.stateis idle / playing / paused / stopped, andon_change(state, line)reports it.- A new
speak()replaces a playing or paused reading. MAX_CHARS(600) is unchanged.- Mic hand-off: pause and stop both release Artemis's mic. They run the
then=callbacks, so an Artemis query'send_queryruns and the recognizer reopens. Resume and play hold the mic again until the reading ends. Muting voice while paused leaves nothing to play. mobile/ui/speech_bar.py(new): a playback bar above every screen (Ask answers, cards, the tutorial reader's Speak button, Artemis replies all go throughapp.speak). It shows READING ALOUD / PAUSED / STOPPED with the sentence being read. Its buttons are Pause / Resume / Play and Stop / Close (Close hides it). It is hidden when nothing is being read.main.py:speech_state/speech_lineproperties,pause_speaking(),play_speaking(),clear_speaking(), and the bar at the top ofRoot.ui/__init__.pyloads the bar's KV.- Voice commands (
voice_commands.py,artemis.py): - "Artemis pause" (also "pause reading", "hold on") pauses the reading.
- "Artemis resume" (also "continue", "keep reading", "go on", "play", "read it again") resumes a paused reading, or reads a stopped one from the top.
- While idle these need the wake word, so an overheard "continue" does nothing.
- The mic is off while a voice plays, so in practice these are heard while a reading is paused or stopped.
- Version: ships in 0.1.16 with the storage figure, the tutorial trainer and Go deeper. It was bumped to 0.1.17 here, then set back so everything since the 0.1.15 APK ships as one version.
Verified:
test_speech_controls.py(new) passes 74/74. It uses a fake speaker and the realArtemisControlleron a fake clock and mic. It covers:- pause mid-reading, then resume from the paused sentence;
- stop, then play from the beginning;
- pause and stop releasing the mic and ending the query hold;
- "Artemis resume" / "Artemis play" re-taking the mic;
- pause, resume and stop being harmless when nothing is playing;
- a new reading replacing a paused one;
- voice off;
- a late end from a cut sentence being ignored.
- Existing suites: artemis ok, phone_questions 74/74, tutorial 77/77.
- In an off-screen desktop run with a silent speaker, the bar showed Pause/Stop, then Resume/Stop, then Play/Close, and hid after Close.
2026-09-27 — Phone storage figure for data gathered on the device — ADDED
Why: the user asked for a calculated number showing how much phone storage the scraped and assembled local information takes. The only existing figure was Handshake's raw per-file KB list from AutoTrainer.sizes(), which had no total and left out caches, logs and corrections.
Added:
mobile/engine/storage.py(new, no Kivy).usage(data_dir)measures every file the app writes inuser_data_dirusing os.stat only. It groups them as:- Saved web pages (
web_library.json) - Stored answers (
ingested.json) - Tutorials (
tutorial_library.json) - Teach-me articles (
tutorials.json) - Training and teachings (
training.json,corrections.json) - Auto-train progress (
autotrain.json,tutorial_train.json) - Caches and snapshots (
companies_cache.json,sports_cache.json,sports_log.json) - Logs (
aegis-log.txt) - Settings (
settings.json) - Other app data (any other top-level
.json/.tmp/.txtfile) - Each file's leftover
.tmpcopy is counted in its category. Folders are skipped, because on Androiduser_data_diralso holds the unpacked app. Missing files count 0. builtin_bytes()sizes the bundled.sqlitetables.human()formats sizes (1,023 B / 1.5 KB / 4.9 MB).rows()builds label/value pairs and ends with "Total gathered on this phone".mobile/ui/storage_info.py(new) measures on a thread and adds item counts from the stores already in memory: web pages, stored answers, tutorials and teach-me articles.ui/engine_page.py: a STORAGE card in Settings (before About) with one row per category, the total, and "Built-in library (ships with the app): N MB, not counted above". It refreshes each time Settings opens.ui/trainer.py: Shield of Zeus shows a one-line total under the Auto-train counts, refreshed when the screen opens.- Version 0.1.16.
Verified:
test_storage.py(new) passes 44/44. It checks that everyAutoTrainer.DATA_FILESname, store file constant and data file named inmain.pyis counted exactly once. It also covers an empty or missing folder, per-category sums with.tmpleftovers, Other app data ignoring folders and non-data files, total = sum of categories = sum of files, the row labels and counts, built-in.sqliteonly, and 14human()cases.test_autotrain.py59/59 andtest_tutorial.py66/66 still pass. The edited KV strings parse with Kivy 2.3.1, andmeasure()runs against a stand-in app.
2026-09-27 — Phone Tutorials page: sourced tutorials built from the web, kept offline — ADDED
Why: in the 0.1.14 session (/tmp/tank3-1456/), "teach me about the ubuntu terminal" failed ("couldn't build a tutorial … from what's on this phone"). "teach me the basics of python / php / html / css" each gave a short one-source Wikipedia "step by step". The user wants a tutorial page: name a subject and get a comprehensive tutorial built from the internet, especially programming forums.
Added:
mobile/engine/tutorial_sources.py(new). It uses only keyless public endpoints:- Wikipedia and Wikibooks action APIs (Wikibooks chapters are read as HTML so code keeps its lines).
- Stack Exchange API 2.3 without a key. Replies are gzip-decoded. The
backofffield pauses that method. Athrottle_violation(error 502) orquota_remaining0 marks the API busy inpolite.py, adds a note, and the other sources carry on. It picks Stack Overflow (tagged by language), Ask Ubuntu, Unix & Linux, Super User or a subject site, and keeps the accepted (else best, score ≥ 1) answer with author, link and licence. - Documentation and tutorial pages from webscan's Bing/DDG tiers, official docs first. A page is read only if its robots.txt allows it; sites already read through an API are skipped.
- Reddit is not used: its listings need an OAuth app, and anonymous
.jsonaccess is outside its terms. Forum HTML is not scraped. mobile/engine/tutorial.py(new).build()asks the sources in parallel within a 45 s cap and assembles sections extractively: What it is, Key concepts, Getting started (Steps for a how-to), Worked examples (code kept verbatim), Common problems and fixes (Q&A with meta), and Further reading.- Every section lists its sources.
- A build with fewer than 2 sites or 3 sections is marked short.
- With no source it says so plainly and saves nothing.
- Search the web off: only saved tutorials open.
- Optional local AI may reword the overview only, if
llm.grounded()passes. - It has its own sentence splitter, because
extractive._split_sentencesrewrites text ("For example" → "For. example") and drops short sentences. TutorialLibrarykeepstutorial_library.json(60 newest; atomic write).mobile/ui/tutorial.py(new). The Tutorials page has a mic, a subject field, Build, live progress with seconds, a web-off note and the saved list. The reader has a header (status, sources, notes, Rebuild / Speak / Delete), one card per section, code in a mono panel, a "From …" line per section and link rows.- Wiring:
main.py: the two new screens,tutorial_library,open_tutorial/show_tutorial, and Auto-train waits while a tutorial builds.ui/choose.py: a Tutorials card (newsageaccent inui/widgets.py).ui/engine_page.py: "teach me X" / "tutorial on X" in the shell opens the page and builds X; the Library shows a tutorial count.voice_commands.py/artemis.py: "open tutorials".polite.py: Wikibooks and api.stackexchange.com count as API hosts.
Verified:
test_tutorial.pypasses 66/66 with faked network. It covers:- a programming topic (code blocks with line breaks, accepted answers with author and licence, robots-disallowed page never fetched, every sentence and listing found in a fixture);
- photosynthesis from Wikipedia alone, marked short;
- a made-up topic, which gives "none" and saves nothing;
- web off;
- save, reload, reopen offline, rebuild, failed rebuild keeping the copy, and delete;
- Stack Exchange throttle, quota, backoff and a broken gzip reply;
- "open tutorials".
- Existing suites are unchanged: misses 92/92, phone_questions 74/74, artemis ok, companies 75/75, autotrain 59/59, provenance 20/20, common_questions 14/14, ingestion 91/91 and 113/113, overview 84/84, sources 22/22, ingest 11/11, sports 113/113.
- Live builds from this laptop: "the ubuntu terminal" gave 7 sources with code in 12 s; "teach me the basics of python" gave 9 sources; "photosynthesis" gave Wikipedia + Wikibooks; "flurbnik quantum yodeling" gave an honest none.
- The desktop Kivy run (temporary venv, temp data dir) showed the choose card, the page, the reader and the saved list, and routed "teach me …" to the page.
Not verified:
- Not tried on the phone; this needs the next APK. It ships in the same build as the 0.1.15 fixes above; the spec was not bumped for this.
- Stack Exchange's keyless quota is shared per network address, so a busy carrier NAT can exhaust it sooner.
Backup: .aegis-doctor/backups/mobile-tutorial-20260927-150044/ and …-151440/ (docs)
2026-09-27 — Phone 0.1.14 session fixes: dropped years, bare questions, how-to subjects, distance endpoints — FIXED
Why: the 0.1.14 session pulled to /tmp/tank3-1457/ showed five problems. (1) Years were cut from the end of sentences: "released in 1995. The PHP ..." became "released in." because _NUMBERED_ITEM_RE read "1995." as a numbered-list item. (2) A cut-off voice question, "how do i", ran a 9 s search and was queued for an auto-train retry. (3) "how do i work in the terminal on ubuntu" got the subject "in terminal on ubuntu", which produced the junk follow-up "what is in terminal on ubuntu". (4) Distance answers could skip one endpoint. "from the moon of Earth to Mars" got an Earth–Mars figure (56 million km), and "moon to mars" got Phobos's orbit, because the word check allows one missing word and counts the title "Moons of Mars". (5) A one-site answer said "Checked against search results from different sites."
Fixed (0.1.15):
mobile/engine/extractive.py(the only change there):_NUMBERED_ITEM_REis now(?<=[.!?:;])\s+(?=\d{1,2}\.\s+[A-Z]). A list item must be 1–2 digits and follow sentence punctuation.webscan.readable()ends every block with.!?:;, so real list items still split.mobile/engine/qparse.py: newbare_stem()andincomplete_reply().answer.py_answer()replies "How do you what? Say the whole question." (qtypeincomplete), with no search, nothing stored, andengine.lastunchanged.question_bank.trainable()rejects bare stems.autotrain.note_user()returns before history or the retry queue.provenanceshows no notice for them. "who are you", "how are you" and "how do i live" are still whole questions.mobile/engine/checks.pyprocess_shape(): how-to subjects drop leading prepositions and articles ("terminal on ubuntu", "account", "netflix").question_bank.follow_ups()no longer builds subject follow-ups for how-to questions (the answer is steps). The connected page titles still become follow-ups.mobile/engine/answer.pyaccepts(): for intentdistance, the subject ("moon and mars") is split on " and ", and the passage must name both ends. For the Moon that means "the Moon", "Earth's Moon" or a capitalized "Moon"; not "moons", "a moon of Mars" or "the moon Phobos". The page title counts only when it is that thing's own page ("Moon" still answers "It orbits Earth at 384,399 km"). A distance miss now suggests "Try naming both places".mobile/engine/provenance.py: "from different sites" only with 2 or more distinct sites. With one site it says "Found in search results from one site only."mobile/buildozer.specis now 0.1.15.
Verified: over the 147 saved pages in web_library.json, the old regex cut off 275 numbers on 70 pages: 244 years, plus 31 short numbers such as "HTML 4." and "count to 67.". None of them was a real list item. The new regex cuts off 0. Sentences ending in a year went from 18 to 255. Sentences ending in "in." / "since." / "of."-style words went from 170 (60 pages) to 11 (8 pages), and all 11 are natural endings ("in between.", "and so on."). The sentence total is unchanged at 4303. With the phone's Moon–Mars pages minus WhiteClouds, the old check answered "56 million km" (Earth–Mars); it now gives an honest miss. With WhiteClouds it answers from its "minimum distance from Mars to the Moon" sentence. Suite results: test_misses.py 92/92, test_phone_questions.py 74/74, test_autotrain.py 59/59, test_provenance.py 20/20, test_companies.py 75/75, test_ingestion_part1.py 91/91, test_ingestion_part2.py 113/113, test_overview.py 84/84, test_sports.py 113/113, test_sources.py 22/22, test_common_questions.py 14/14 offline, test_ingest.py 11/11. test_artemis.py passes and test_tutorial.py passes 66/66. The new tests use real sentences from the phone data, and they fail on the old code. Not yet verified on the phone: it needs the 0.1.15 APK.
Backup: .aegis-doctor/backups/mobile-answer-fixes-20260927-150409/
2026-09-27 — Phone auto-train waited for Wi-Fi on mobile data — CHANGED (0.1.14)
Why: the phone was on T-Mobile LTE with Wi-Fi on but not connected, so auto-train stayed on "Waiting for Wi-Fi". "Only on Wi-Fi" (auto_train_wifi) was on by default. Ordinary web searches already used mobile data. The user wants auto-train to use whatever network the phone has.
Changed:
mobile/main.py: theauto_train_wifidefault is nowFalse. The UI andmobile/engine/autotrain.pyalso fall back toFalse.mobile/engine/autotrain.py:device_state()now also returns"online"(any active network, fromConnectivityManager). When the phone has no network at all, the gate shows "Waiting for a network" andwaits_on()lists "a network". This applies whether Only on Wi-Fi is on or off. If Only on Wi-Fi is on and the phone is on mobile data, it still shows "Waiting for Wi-Fi".- Settings > "Only on Wi-Fi" now says: "Off: auto-train uses whatever network the phone has, Wi-Fi or mobile data, and waits only when there is none." The Trainer card says it runs over Wi-Fi or mobile data.
- On the phone,
files/settings.jsonnow hasauto_train_wifiset tofalse(the app was force-stopped first). No migration code was added.
Verified: test_autotrain.py passes 57/57. The new checks: no network waits for a network (with Wi-Fi-only on and off), Wi-Fi-only on mobile data waits for Wi-Fi, the default runs on mobile data, and main.py DEFAULTS has the flag off. test_provenance.py passes 17/17 (its miss now says "once it has a network and the charger"). test_misses.py 74/74, test_phone_questions.py 61/61, test_artemis.py and test_companies.py 75/75 also pass. The phone's settings.json was read back after the write. The code default ships with the 0.1.14 APK.
Backup: .aegis-doctor/backups/mobile-autotrain-network-20260927-144250/ (includes the phone's original phone-settings.json)
2026-09-27 — Aegis Mobile 0.1.13 crashed in the voice listener (SIGSEGV) — FIXED (0.1.14)
Symptom: on the Tank 3 Pro, about 2½ minutes after the last question, the app died and restarted. aegis-log.txt showed AttributeError: 'SpeechListener' object has no attribute '__javamethods__' in jnius.PythonJavaClass.invoke. Logcat then showed Fatal signal 11 (SIGSEGV) in SDLActivity, with PyObject_ClearWeakRefs in libpython3.14.so under android.speech.SpeechRecognizerImpl$InternalRecognitionListener.handleMessage.
Cause: the Voice button's start (_patient_start in mobile/engine/artemis.py) and plyer's own Android _start (used by Artemis) both created the SpeechListener as a local variable. They also made a new SpeechRecognizer on every listen without destroying the old one. pyjnius frees a PythonJavaClass once Python drops it, but Android still holds it, so a late recognizer message landed in freed memory.
Fixed (mobile/engine/artemis.py):
_own_start()replaces_patient_start()and is used for both Voice and Artemis. Only Voice gets theSILENCE_MSextras.- It destroys the previous recognizer before creating a new one.
- It keeps the last 4 listeners referenced in
_LIVE_LISTENERS. buildozer.specis now 0.1.14.
Verified: test_phone_questions.py passes 61/61. The new G checks: Artemis gets no silence extras, the previous recognizer is destroyed, and the listeners stay referenced. test_artemis.py passes, test_companies.py 75/75, test_misses.py 74/74. Not yet verified on the phone: it needs the 0.1.14 APK.
Also found in the same device log (OPEN, not fixed):
- "how much is the company lithium america's corp worth":
qparse.company_ask()keeps "company" in the name, soCompanies.resolve()finds nothing. Without "company" it resolves to LAC. - FIXED (0.1.15, see "Phone 0.1.14 session fixes" above): "how many miles is it from the moon to mars" answered with Phobos's orbit ("Moons of Mars").
_distance_okdoesn't check that the passage names both bodies. It was flagged "other sites disagree" but still shown, and auto-train logged it as answered. - Not Aegis: the phone's vendor
fingerprint@2.1service crash-loops every 5 s.
Backup: .aegis-doctor/backups/mobile-voice-listener-20260927-144032/
2026-09-27 — Nonsense queries got confident junk answers (gate was switched off) — FIXED
Why: main.py already had the nonsense gate v3 (early, after the local/web fetch, and late, before synthesis). It refuses when fewer than half of the query's tokens appear in the top local title/body or the top 5 web titles. Both refusals had been switched off with if False and ... ("temporary, for pilot"), and there was no record of why. So "blorptastic quantum marshmallow" returned a "Found: 5 local" dump, "how tall is the zibbledorf tower" got a refinery tower, and "what color is the number seven" got pH strips.
Fixed: removed the two if False and guards and the stale comment. No thresholds changed.
Verified (20 gibberish + 20 real prompts, on copies of the live DB, AI answers off):
| run | gibberish answered | real refused | real answers changed |
|---|---|---|---|
| gate off, web off | 10/20 | 0/20 | — |
| gate on, web off | 3/20 | 0/20 | 0 |
| gate off, web on | 10/20 | 0/20 | — |
| gate on, web on | 3/20 | 0/20 | 0 |
| patched live code, web off | 4/20 | 0/20 | — |
- The gate doesn't reach three of them:
- "when did the frobnicator war end" goes through the definition path.
- "what is the capital of wakandastan" gets the honest "Could not find a Wikipedia article".
- "define flibbertigibbetish quorum" gets the general dump.
- The fourth in the live run, "explain the grumbleflux theorem", timed out in the earlier runs and this time finished through
/teachwith an invented tutorial (OPEN_ITEMS 8). py_compileis clean.
Backup: .aegis-doctor/backups/nonsense-gate-20260927-135230/
2026-09-27 — "who is Marie Curie" answered "1867" (loose correction match) — FIXED
Why: the keyword fallback in query_corrections.get() divided the shared keywords by the query's keywords only. "who is marie curie" has the keywords {marie, curie}, and both appear in the stored "what is the birth year of marie curie", so that scored 1.0 and the birth year won over local search. Question words are stopwords, so "who" vs "what year" never counted.
Fixed (query_corrections.py):
- The score is shared / union of both prompts' keywords (Jaccard). The threshold stays 0.70 with at least 2 shared keywords.
question_class()reads person / date / place / number from the raw prompt, after "can you tell me"-style lead-ins. Two classified prompts of different classes never match; what/which prompts stay unclassified.- Ties break on score, then shared count, then
used_count, then prompt, so the winner no longer depends on row order. get()reports the shared keywords in_shared.
Verified (on a copy of the live DB):
- 962/962 stored prompts still reach their own row through
get(). - Replay of the 627 distinct prompts in
query_log(7,340 rows): 469 no match either way, 127 exact, 27 same keyword match, 2 switched and 2 dropped. - Switched: both "who is Marie Curie" spellings now get the "who was marie curie" answer.
- Dropped (both were wrong): "what is natural selection" no longer gets "who proposed natural selection", and "what is the chemical symbol of what is the" no longer gets oganesson.
- Paraphrases of stored prompts reaching their own row: 3,181 → 3,199 of 3,308. The 3 "lost" land on an equivalent duplicate row.
- "when was marie curie born" and "what year was marie curie born" match nothing, so they go to search instead of borrowing a row.
t_correction_hitpasses.- Note: the "birth year of marie curie" row's high
used_countwas inflated by the old loose matches.
Backup: .aegis-doctor/backups/corrections-match-20260927-133716/
2026-09-27 — Ollama keep-alive 30m → 5m, adjustable on the Handshake page — CHANGED
Why: on this 3.2 GB host the model (~1.3 GB) stayed resident for 30 minutes after every AI answer, and that memory is what pushes the machine into swap.
Changed:
config.OLLAMA_KEEP_ALIVEdefaults to "5m" (AEGIS_OLLAMA_KEEP_ALIVEstill wins).synthesis.pyreads it on every call (_keep_alive()), so a change applies to the next answer.settings.py: newai_keep_alive_minsetting (0–30 minutes, 0 = unload after each answer). It appears as a slider on the Settings & Permissions tab and parses env values like "30m", "1h", "90s" or "300".
Verified: the default gives "5m", setting 0 gives "0", 99 is capped to "30m", and env "2h" wins at startup. py_compile is clean.
Backup: .aegis-doctor/backups/ai-keepalive-20260927-133920/
2026-09-27 — Disk: old DuckDB backups deleted (~6.2 GB) — CLEANUP
Why: data/ held five full copies of the DB from 2026-09-19 (~630 MB each) next to a live DB that is 28 MB since the Sep 25 import compacted it.
Done: the live DB was checked first (documents 2,091, facts 918, corrections 962, query_log 7,340, reports 1,084; FTS "hydrogen" returns 189 hits). Then aegis.duckdb.bak.1789875371, .1789875844, .1789875906 and .1789875952 were deleted, which freed ~2.5 GB. The newest, aegis.duckdb.bak.1789876094, is kept.
Also deleted (user asked: "Do we need it? If not get rid of it"): .aegis-doctor/backups/import-20260925-190231/ (3.7 GB), the snapshot of data/ that aegis-import set aside. It held the Sep 20 DB, the same five Sep 19 copies, logs and small files.
- Its DB was compared with the live one read-only, table by table and row by row (all columns). Every table exists in both, and live has at least as many rows in each.
- 4 old correction rows differ only by later updates (higher
used_count, two answers edited). - 14 old
facts_pendingrows were promoted intofactsunder the same IDs. - No other old row is missing. The small files are identical to the ones in
data/, or older versions ofaegis.log/device_benchmark.json.
2026-09-25 — Updating a second laptop: requirements check, knowledge export/import, transfer folder — ADDED
Why: the user's second, more powerful laptop has an older Aegis install. It had to be brought to exactly this machine's code and knowledge. There was no way to list what a machine is missing, no way to move the knowledge without copying the 2.8 GB DuckDB, and no safe code sync.
Added:
requirements_check.py/./aegis-check: 27 checks.- What it checks: the backend and Kokoro venvs against their pins, GTK/WebKit, tools, Ollama and its model, the Kokoro unit and weights, the Vosk model, the data, the mic, memory, ports, system files and
.env. - Output: OK / WARN / MISSING with a fix for each item.
--fixruns user-level fixes only and prints sudo ones. - The
aegislauncher runs the quick check (~0.5 s) and offers--fixwhen something critical is missing. GET /system/requirementsfeeds a card on the Handshake System tab.requirements-kokoro.txtholds the Kokoro pins;system/holds copies of the Kokoro unit andspeak.knowledge_bundle.py,./aegis-shareand./aegis-import:- The export writes every DuckDB table to Parquet (ZSTD) with a manifest (DDL, rows, sha256), plus the mobile pack and settings. It takes about 10 MB and 5 s, read-only or through the backend.
aegis-shareserves the export once over the LAN behind a random token.--out DIR --no-servewrites it to a folder instead and first secret-scans every text column.aegis-importtakes a URL, a.tar.gzor a folder. It rebuilds the DB in a staging dir, including the schema, the FTS index and a test search, then backs up and swaps it in. It keeps the target's mic device.secret_scan.py: the shared secret patterns, with a word-boundary check so URL slugs like "risk-management" don't match../aegis-update(runs on the old laptop):- It syncs an install from
aegis.bundleor the code tarball, after a code +.gitbackup and with a generatedrollback.sh. - It points git at the source commit and keeps the old HEAD as
refs/aegis-backup/pre-update-<ts>. - Old-only files are listed. They are moved into the backup only with
--delete-old-only. - It never touches
data/,.env, the venvs, models or logs. It stops only a backend running from the target folder. ./aegis-transferandtransfer/START-HERE.md: they build~/Desktop/aegis-transfer/with START-HERE.md, the repo bundle and tarball, knowledge/, pip freezes, system/, the APK build page with its guide and extractor, a freshaegis-handoff --db, the tools, MANIFEST.json and SHA256SUMS.- The whole folder is secret-scanned: text files, the pack, the tarball, and every blob in the git history.
.gitignorenow ignores.envand.env.*(except.env.example). Before, an update simulation showed.envas untracked, sogit add -Acould have committed it.
Verified (this machine, with Cursor open):
aegis-check: 24 OK, 3 WARN, 0 MISSING.- The Handshake card renders with no JS errors.
- Share over the LAN: a bad token returns 404, and the server stops after one download.
- Folder import into a temp data dir: all 10 tables match, the FTS index covers 2,091 docs, "hydrogen" returns 3 hits, about 9 s and 310 MB RSS. The source folder is unchanged afterwards.
- Transfer folder:
sha256sum -cpasses. A clone of the bundle equals HEAD, including the tags. - Update simulation on
git archive 7a1adb6with a fakedata/and.env: every tracked file matches HEAD byte-for-byte, and the protected files are unchanged. - Same with a git checkout at 7a1adb6:
--delete-old-onlyworks, androllback.shrestores 7a1adb6 exactly.
Not verified: the real update on the other laptop, pip installs against its Python version, and the APK build there.
2026-09-25 — Aegis Mobile standalone APK — ADDED
Why: the old phone version was split across ~/aegis-mobile-ui (a Kivy chat UI) and ~/aegis-termux (a toy HTTP bridge with a 3-document corpus and phi3). It only worked with Termux and Ollama running on the phone, had no real knowledge, and there was no APK build.
Added (mobile/, see HANDOFF section 15):
- A standalone offline engine inside the app, with no server. Pipeline:
- exact corrections
- math and units
- facts
- definitions
- BM25 search plus the laptop's extractive answer picker
- optional local AI (Ollama
qwen2.5:1.5b, off by default, falls back when unreachable)
export_mobile_pack.py: a 5.1 MB SQLite pack from the 2.8 GB DuckDB (2,091 docs, 918 facts, 962 corrections). It needs the backend stopped.- A Kivy app with Ask, Library (with a self-test) and Settings screens, Android text-to-speech, and dp sizing, big tap targets and
below_targetkeyboard handling for the Tank 3 Pro. buildozer.specfor Android 15:android.api = 35,minapi = 26,arm64-v8aonly, the SDK license accepted automatically.build-apk.sh: refuses while Cursor is running or under 2000 MB free, caps Gradle at 1 GB with one worker, and prints the USB and "Install unknown apps" install steps.run-desktop.shto try the app in a phone-shaped window.- The prototypes were moved to
archive/mobile-prototype/.
Verified: engine self-test 7/7 in about 37 ms. Stub-Ollama AI path and fallback. Desktop screenshots of every screen.
Not verified: the APK build (needs Cursor closed and sudo apt install libncurses-dev) and on-device behaviour: edge-to-edge padding on Android 15, text-to-speech, and Termux Ollama.
2026-09-25 — Handshake page, live tests, settings and permissions — ADDED
Added:
- Red Handshake button on the launcher. It opens
/handshake, which has six tabs: - Handshake: build, view and copy the AI handoff bundle.
- System: live memory, services and processes.
- Query Engine: self-tests, pipeline, speed stats.
- Training: read-only trainer self-tests and stats.
- Storage: every file group and DB table with sizes.
- Settings & Permissions: mic allow/deny, device picker, mic test with level meter and transcript, voice, internet, local AI, Artemis, warm-up, AI memory gate and time cap, fullscreen.
settings.py: settings are stored indata/settings.json. At startup an env var wins, then the file, then the default. Changes apply live where possible.desktop.py:- reads saved settings at startup,
- new
aegisControlchannel (Artemis on/off, released during the mic test, settings reload), - doesn't start Artemis when the mic is blocked.
- Backend:
/voice/transcribeand the audio branch of/voice/commandreturn 403 when the mic is blocked. - Voice commands: "open handshake", "open settings", "permissions".
Changed: AEGIS_LLM_MIN_FREE_MB default is now 1600 (was 900) in synthesis.py. This mitigates the 2026-09-25 freeze. The value is adjustable on the Settings tab.
Found by the new tests (OPEN):
- "who is Marie Curie" answers "1867": a user correction matches too loosely and wins before local search. (FIXED 2026-09-27, see the entry at the top.)
- The fact extractor on "Paris is the capital of France." yields
country = france/definition = capital of Francerather thancapital = Paris.
Not verified: the desktop control channel, because the desktop can't be run alongside Cursor. Check it with Cursor closed.
2026-09-25 — desktop + Cursor froze the host; AI handoff system — OPEN / ADDED
Symptom: After the launcher's Cursor refusal was removed, ./aegis was started with Cursor open. The host froze within ~2 minutes (hard reboot, no OOM kill logged, so it was swap thrashing).
Likely cause: Startup warm-up (synthesis.warm_async) loaded qwen2.5:1.5b (~1.3 GB) because ram_ok() only requires AEGIS_LLM_MIN_FREE_MB = 900 MB available. Kokoro holds ~950 MB from boot and Cursor ~2.5 GB. Open: raise the threshold and/or skip the warm-up when Cursor or a browser is running. Measured memory table is in HANDOFF.md section 2.
Added:
HANDOFF.mdrewritten as the full system doc.AI_PROMPT.md: copy-paste command workflow for outside AIs../aegis-handoff: read-only, redacted, leak-checked bundle split into paste-sized parts, withshow/grep/logs/status/copyhelpers.- Old
handoff.shmoved toarchive/. - Baseline commit
7a1adb6.
2026-09-24 — stray numbers in answers; stitched answers — FIXED
Symptom: Answers showed [1]..[13] bubbles, copied Wikipedia footnotes (1970[a][I], follows:[14]), and snippet prefixes (4 days ago - ). Answers were 4 stitched page sentences, often off-topic.
Fix: extractive_qa.strip_markers() cleans source text and every /query answer (_sanitize_query_output in main.py); no [N] markers are added any more. Desktop backend now runs with AEGIS_QUERY_ALLOW_LLM=1: synthesis.py writes 1-3 conversational sentences, rejects answers whose numbers appear in no retrieved text, skips the LLM when free RAM is low (AEGIS_LLM_MIN_FREE_MB, default 900) and cancels generation past AEGIS_LLM_CAP_S (default 15). Without the LLM the best 1-2 sentences are used; when nothing answers the question the reply says so and background research starts (answer_qtype: no_answer).
Also: query_expand.expand referenced seen before assignment when the LLM call failed, silently dropping search variants.
Known slow case: under memory pressure (e.g. Cursor open, swap in use) the 1.5B model misses the 15s cap and the extractive fallback is shown.
2026-09-24 — Artemis ignored wake word — FIXED
Symptom: Saying “Artemis” produced no response.
Cause: Free-form Vosk (small en-us model) transcribes “Artemis” as “art amiss” / “artem s” / “art artem mess”, which the wake regex missed. Worker stderr went to /dev/null, so nothing was visible.
Fix: artemis_listen.py feeds audio to a grammar-constrained recognizer (wake word + command phrases) alongside the free-form one; grammar decides wake/commands, free-form carries spoken questions. Logs to /tmp/artemis.log (worker stderr → /tmp/artemis-worker.err); the desktop prints artemis heard: ... to the session log.
2026-09-23 — Artemis wake-word + silent answers + SOZ UI — ADDED
Artemis: Always-on wake word via artemis_worker.py (Vosk). Say “Artemis” → “Your wish is my command.” Then “Activate Aegis” → Query Engine + “Enter question or query to continue.” Answers are silent; Speak button on the answer card reads them.
UI: Query Engine restyled with SOZ bronze/midnight (aegis-engine.css).
Env: AEGIS_ARTEMIS=0 disables continuous listen; AEGIS_SPEAK_ANSWERS=0 default.
2026-09-23 — Exit button (full stack) — ADDED
Feature: Fixed Exit control on launcher, Aegis (/), and SOZ (/soz). Confirms, then POST /app/exit → stop mic/Kokoro, backend, and close the GTK desktop window.
Files: static/aegis-exit.{css,js}, app_exit.py, desktop poll of /tmp/aegis-exit.request.
2026-09-23 — voice commands (STT) — ADDED
Feature: Push-to-talk mic in desktop header. Local Vosk STT (models/vosk-model-small-en-us-0.15) + command router.
Commands: mute / unmute · home / launcher · open shield / soz · open aegis · ask/query/what/who/… → Query Engine (spoken answers via existing Kokoro path).
Env: AEGIS_MIC_DEVICE (default plughw:0,7 DMIC16k), AEGIS_LISTEN_SECS (default 5), AEGIS_VOSK_MODEL.
API: POST /voice/command (WAV), POST /voice/transcribe, GET /voice/stt.
Verify: mic button in ./aegis; or curl -s -X POST --data-binary @/tmp/aegis-ptt.wav http://127.0.0.1:8000/voice/command
2026-09-23 — desktop voice (Kokoro) not wired — FIXED
Gap: Desktop never started/verified Kokoro (:5050). Query auto-speak was removed; WebView had enable-media=False (host aplay only).
Fix (2026-09-23):
./aegis+desktop.pypreflight: startkokoro-server(systemd or.venv-kokorofallback), speak “Aegis is online. Ready.”, leave TTS up.- Header mute button →
/voice/enable|disable;/voice/status. - Optional answer TTS via
query_log+narration.speak_raw, on for desktop (AEGIS_DESKTOP=1//voice/answers/enable). Training keepsAEGIS_SPEAK=0.
Verify: curl -sf :5050/health; ./aegis (quit Cursor first).
2026-09-23 — extract.py _sentences NameError (facts generate) — FIXED
Symptom: Backend log flooded with [Aegis] generate: extract failed on web::…: name 'body' is not defined (~143 hits). SOZ fact generation / facts_pending population broken.
Root cause: A pasted AEGIS-FIX block inside _sentences() called _sentences(body) / facts.extend(...) with undefined names (and would have recursed forever if body existed). Concept/attribution/formula matchers were also never registered in _MATCHERS.
Fix (2026-09-23):
- Restored
_sentencesto pure sentence splitting. - Normalized the three prose matchers to the shared hit shape (
attribute/value/confidence) and appended them to_MATCHERS.
Verify: .venv/bin/python -c "from extract import _sentences, extract_facts; \ print(_sentences('Hydrogen is a chemical element. It has symbol H.')); \ print(extract_facts('Hydrogen','Hydrogen is a chemical element. Symbol is H.','t1'))"
2026-09-23 — desktop wrapper kill-race / fake boot / CDN fonts — FIXED
Issues: desktop.py always killed :8000 then spawned main.py; ./aegis pkill'd healthy uvicorn; launcher used DEMO timed boot; Google Fonts CDN + CSS glitch in loading.html.
Fix (2026-09-23):
- Reuse healthy
/health; only own a child uvicorn if started here. - Offline error HTML when backend down; lighter WebKit settings.
- Launcher polls
/health+/store/stats+/reports/stats. - System font stacks;
.desktopentry at~/.local/share/applications/aegis-terminal.desktop. - Launch:
./aegisor app menu "Aegis Terminal".
2026-09-19 — attribute-value misalignment in table-formatted sources — FIXED
Repro: curl -s -X POST http://127.0.0.1:8000/query \ -H 'Content-Type: application/json' \ -d '{"prompt":"what is the boiling point of nobelium"}'
Expected: "Unknown" (RSC says boiling point is undetermined) or empty. Actual: "Nobelium – Melting Point ... Melting point | 827°C ..."
Source doc: web::900b7ad6883332ba https://periodic-table.rsc.org/element/102/Nobelium Body: "Melting point | 827°C, 1521°F, 1100 K | ... Boiling point | Unknown"
Two gaps:
- Extractor produced nothing from this doc — facts_pending has no rows for either melting_point or boiling_point. RSC's table format (
| Attribute | Value |) isn't parsed.
- build_answer picked the melting-point row to answer a boiling-point question. Even if a fact existed, the picker doesn't pair the queried attribute with the value slot.
Root cause (diagnosed 2026-09-19):
extractive_qa._score_sentenceawards +1.5 for any sentence with a digit when qtype == "number". It cannot tell which attribute that digit belongs to.- The RSC nobelium table is one long sentence-run (no periods until "Image explanation..."). It contains "boiling point", "melting point", and 827°C in separate cells. Score ~8.06.
- The correct candidate "The boiling point represents..." scores ~7.33. Table wins by 0.7 pts, entirely from the number bonus.
Attempted fix (2026-09-19): patches/picker-attr-alignment.md. Applied, tested, reverted. Does not work. Three findings:
- The filter was gated on
is_qty, which_question_typereturns only for 'how many' / 'how much'. 'what is the X of Y' queries never hit the block. Guard change alone is insufficient (see 2). - Even without the guard, the RSC table candidate contains
Boiling point | Unknown._attr_value_alignedaccepts it because_UNKNOWN_VALUE_REmatches 'unknown'. The candidate survives with its ~8.06 score and wins anyway. - Structural: the picker scores whole sentences. A table row with multiple
attr | valuecells cannot be disambiguated by any filter over whole sentences. The value must be extracted first.
Correct fix: structured table extraction.
- In
extract.py(or a new pass before_score_sentence), detect| Attr | Value |patterns and emit each cell as its own candidate with a tag for the attribute name. _score_sentencethen prefers the candidate whose tag matches the asked attribute.- This is the long-term fix. Not a same-night patch.
Reverted: extractive_qa.py restored to the state before the attempt. The patch file remains in patches/ for reference.
Not urgent: known, reproducible, doesn't corrupt data.
2026-09-19 — preflight symbol match false positive — FIXED
Log line on every element query: [Trainer] preflight symbol match: 'the boiling point of nobelium' -> 'th'
'th' matched the substring 'the' because the symbol fallback fired on the whole prompt (which the subject extractor returned instead of the bare element name). Both trainer.py call sites (preflight and canonical_lookup) now guard the fallback with:
if len(k_clean) <= 3 and k_clean.isalpha():
Real cases ('al' -> aluminum, 'c' -> carbon) still work. Multi-word subjects skip straight to canonical name lookup.
Fixed in commit 97863b4. Verified: grep of /tmp/aegis-backend.log for 'symbol match' returns nothing after a batch of element queries.
Resolution (2026-09-19): Fixed in extractive_qa.py (2b19936). _expand_table_pairs rewrites | A | B | C | D | lines as A of <subj> is B. C of <subj> is D. before scoring, so each table cell becomes its own candidate. build_answer limits fact-shaped quantity queries to one sentence.
Verified: what is the boiling point of nobelium -> 'Boiling point of Nobelium is Unknown.' (previously: RSC melting-point 827°C table row)
2026-09-19 — extract.py doesn't parse table-formatted docs
Observation: RSC nobelium page (web::900b7ad6883332ba) contains Melting point | 827°C and Boiling point | Unknown in a table, but no facts_pending rows were created for either.
Impact: The picker can still answer from the doc body via the new _expand_table_pairs path (fixed in 2b19936), but the fact-table preflight never gets populated for these sources. Every query re-runs the picker instead of hitting a confirmed fact.
Fix location: extract.py — teach extract_facts() to detect | Attr | Value | lines and emit candidate facts with attribute names normalized against the canonical attribute vocabulary.
Not urgent: picker path works; this is an optimization.
2026-09-19 — no hedge-word penalty in sentence scoring
Observation: extractive_qa._score_sentence has no penalty for "predicted", "estimated", "theoretical", "calculated". A sentence with a predicted value scores the same as one with a measured one.
Impact: For synthetics (Z >= 100) most published values are predictions. The picker can surface a predicted number without distinguishing it from a measured one. This is the class of problem that started the whole session.
Fix location: extractive_qa._score_sentence — apply a multiplier (0.5?) when the sentence contains any hedge word and no "measured"/"observed" qualifier.
Not urgent: canonical refuses when only a prediction exists. This would tighten the web-doc path.