FLINT_HEAL_MODEL_CHOICE_STICKS_v0.1 (tiny) CHANGELOG 2026-09-29 2:28 PM MDT: re-checked on mains FL 7db2d94 / Alpha 395c761 (unchanged). The FL diff applies with plain git apply on main and on PR #124's branch (env heal). After #122 and #123 also land, every code hunk still applies; only the ledger hunk needs its text anchor (insert right above "## Out of scope for this brick"; git apply -C1 does it). Re-check commands now name ~/FreeLattice and ~/FreeLattice-Alpha. Local paths: FreeLattice is ~/FreeLattice and the Tree (repo Chaos2Cured/FreeLattice-Alpha, site thelatticetree.com) is ~/FreeLattice-Alpha on Flint's machine. Wherever this paste says "theLatticeTree" it means that repo; "../FreeLattice" from inside ~/FreeLattice-Alpha is correct as written. The model she taps is the model that answers. Both repos. Shared fl-connect.js stays byte-identical. Marker: v-model-choice-sticks-v0 Smoke (FL): SMOKE_OK model choice sticks v0.1 (docs/scripts/smoke-model-choice-sticks.js) Smoke (Alpha): SMOKE_OK model choice sticks alpha v0.1 (docs/modules/model-choice-sticks.test.js) Source: Hypha walk 4 of Connect heal v0.4 + v0.4.1 on the live sites: 9 PASS, 2 HOLD (items 7 and 10), one cause. Written by Grok Bot for Kirk, Mon Sep 28 2026, about 9:45 PM MDT. Full diffs: /workspace/flint-pastes/FLINT_HEAL_MODEL_CHOICE_STICKS_v0.1.diff (FreeLattice) /workspace/flint-pastes/FLINT_HEAL_MODEL_CHOICE_STICKS_v0.1-alpha.diff (theLatticeTree) Both are also inlined at the end (Appendix A and B), so this paste stands alone. Temperature: green braided gold, the second cup stays where she set it down. ====================================================================== 0. ORDER, MAINS, LOCKS ====================================================================== 0.1 Where this lands (new Flint order): 1) FLINT_HEAL_AGENT_BRIDGE_ENV_v0.1.1_tiny.txt (FL only, security, first) 2) THIS heal (both repos) <- new, tiny, before the Tree paste 3) FLINT_NEXT_TREE_ANY_MODEL_v0.1.txt (both repos) 4) FLINT_NEXT_CONNECT_BOOT_PROBE_F1_F2_F3_F4_v0.1.txt (both repos) 5) FLINT_NEXT_120_DOCS_LAYER_v0.1_tiny.txt 6) FLINT_HEAL_LOVE_LOGIC_V3_PHONE_v0.1.txt 7) FLINT_NEXT_LOVE_LOGIC_V4_WALK_THE_GARDEN_v0.1.txt Why before the Tree paste: it is the live HOLD from walk 4, it is small, and it touches none of the lines the Tree or boot probe pastes edit. Verified: on FL main 7db2d94, applying env heal -> this heal -> (a faithful simulation of) the Tree fl-connect.js changes -> the boot probe Appendix A (app.html) and Appendix B (fl-connect.js) diffs all apply with plain `git apply` (no fuzz, offsets only). After all of them: smoke-model-choice-sticks, smoke-connect-boot-probe, every smoke-connect-* (except the pre-existing yesno-wizard) and tests/smoke.js ALL 3597 are green. 0.2 Mains re-checked just before writing: FreeLattice main = 7db2d94 (CI primer-only commit on a80c8e4 #121; app code = a80c8e4) theLatticeTree main = 395c761 (#83) docs/modules/fl-connect.js identical on both mains. Line numbers below are "main N" on those SHAs. Anchor on the quoted TEXT. Re-check: git -C ~/FreeLattice fetch origin && git -C ~/FreeLattice log --oneline -2 origin/main git -C ~/FreeLattice-Alpha fetch origin && git -C ~/FreeLattice-Alpha log --oneline -2 origin/main cmp ~/FreeLattice/docs/modules/fl-connect.js ~/FreeLattice-Alpha/docs/modules/fl-connect.js 0.3 Locks: Layer, never delete. Root sw.js byte-identical to main; docs/sw.js untouched. index.html == docs/app.html (copy after every app.html edit). fl-connect.js byte-identical across repos. No conflict markers. Every inline ', start); + if (end < 0) continue; + const s = app.slice(start, end).trim(); + if (!s || !/^(?:\/[\/*]|function\b|var\b|let\b|const\b|window\.|document\.|\(|\{|if\b)/.test(s)) continue; + try { new Function(s); n++; } catch (e) { assert.fail('inline script parse fail: ' + e.message); } + } + assert.ok(n > 10, 'inline scripts parse: ' + n); + console.log('SMOKE_OK model choice sticks v0.1'); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/index.html b/index.html index fa946fa..5003526 100644 --- a/index.html +++ b/index.html @@ -31549,6 +31549,27 @@ window.flUseBridgePort = flUseBridgePort; window.flBridgeHealth = flBridgeHealth; window.flRememberHelpedBridge = flRememberHelpedBridge; +// v-model-choice-sticks-v0: the Settings button row (Change Provider, Test Connection, +// a mind at home) comes back after Local empties it. Built with createElement/textContent. +function flRefillSettingsActions(actionsDiv) { + if (!actionsDiv || actionsDiv.children.length) return; + function add(label, cls, onTap, green) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = cls; + b.textContent = label; + if (green) { b.style.borderColor = 'var(--emerald)'; b.style.color = 'var(--emerald)'; } + b.addEventListener('click', function() { try { onTap(); } catch (e) {} }); + actionsDiv.appendChild(b); + } + add('Change Provider', 'ai-status-btn ai-status-btn-primary', function() { if (typeof openProviderModal === 'function') openProviderModal(); }); + add('Test Connection', 'ai-status-btn', function() { if (typeof AiSetup !== 'undefined' && AiSetup.testFromSettings) AiSetup.testFromSettings(); }); + add('\uD83C\uDFE0 a mind at home', 'ai-status-btn', function() { + if (window.FlConnect && FlConnect.open) FlConnect.open(); else if (typeof switchTab === 'function') switchTab('connect'); + }, true); +} +window.flRefillSettingsActions = flRefillSettingsActions; + function settingsSetMode(mode) { var browserBtn = document.getElementById('settingsModeBrowser'); var cloudBtn = document.getElementById('settingsModeCloud'); @@ -31589,6 +31610,8 @@ function settingsSetMode(mode) { var stLocal = document.getElementById('settingsStatusText'); if (stLocal) stLocal.textContent = 'Opening Connect. Tap the mind you want there.'; if (actionsDiv) actionsDiv.innerHTML = ''; + // v-model-choice-sticks-v0: the old wizard buttons are gone; put the everyday row back + flRefillSettingsActions(actionsDiv); try { FlConnect.open(); } catch (eOpenL) {} return; } @@ -36259,6 +36282,40 @@ window.FLAutoModel = (function() { } function isVision(name) { return VISION_RE.test(name || ''); } + + // v-model-choice-sticks-v0: is the chosen model still installed? ('qwen2.5' matches 'qwen2.5:latest') + function onInstalledList(models, name) { + if (!name) return false; + var n = String(name); + return (models || []).some(function(m) { + var mn = String((m && (m.name || m.model)) || ''); + return mn === n || mn === n + ':latest' || (n.indexOf(':') === -1 && mn.split(':')[0] === n); + }); + } + // v-model-choice-sticks-v0: a user choice is only replaced when Ollama answers with a list that + // no longer has it. A quiet or empty answer never replaces anything. The words go in with textContent (showToast). + function checkChosenStillHere(current, tabId) { + if (!current || !current.model) return; + if (current.provider && current.provider !== 'ollama') return; + getInstalledModels(function(models) { + if (!models || !models.length) return; + if (typeof FLActiveModel === 'undefined' || !FLActiveModel.isUserChosen()) return; + if (FLActiveModel.get().model !== current.model) return; + if (onInstalledList(models, current.model)) return; + var needsVision = VISION_TABS.indexOf(tabId) !== -1; + var pool = models.filter(function(m) { return needsVision ? isVision(m.name) : !isVision(m.name); }); + if (!pool.length) pool = models; + var pick = String((pool[0] && (pool[0].name || pool[0].model)) || ''); + if (!pick) return; + safeSet('fl_modelChoiceGone', current.model); + FLActiveModel.set(pick, 'ollama', 'auto'); + try { + if (typeof showToast === 'function') showToast('The model you chose, ' + current.model + ', is no longer on this computer. Using ' + pick + ' for now. You can pick another in Connect.', 7000); + } catch (eT) {} + try { if (typeof AiSetup !== 'undefined' && AiSetup.refreshSettingsCard) AiSetup.refreshSettingsCard(); } catch (eS) {} + }); + } + function getPreferred(type) { return safeGet('fl_preferred_' + type + '_model', null); } function setPreferred(type, model) { safeSet('fl_preferred_' + type + '_model', model); } @@ -36269,6 +36326,9 @@ window.FLAutoModel = (function() { // "User choice is sacred" — the auto-switcher must never lose it if (typeof FLActiveModel !== 'undefined' && FLActiveModel.isUserChosen()) { var current = FLActiveModel.get(); + // v-model-choice-sticks-v0: the person's model stays. Only if it left this computer + // does the picker fill in, and then it says so in plain words. + checkChosenStillHere(current, tabId); var needsVision = VISION_TABS.indexOf(tabId) !== -1; var currentIsVision = isVision(current.model); if (needsVision && !currentIsVision) { ====================================================================== APPENDIX B: theLatticeTree full diff vs main 395c761 (git apply --check clean) ====================================================================== diff --git a/docs/library/CONNECT_PORT_PICKER_SISTER_v0.md b/docs/library/CONNECT_PORT_PICKER_SISTER_v0.md index d2e6341..cd688da 100644 --- a/docs/library/CONNECT_PORT_PICKER_SISTER_v0.md +++ b/docs/library/CONNECT_PORT_PICKER_SISTER_v0.md @@ -20,3 +20,14 @@ Marker `v-connect-heal-v0.4`. Test `docs/modules/connect-heal-v04.test.js` print - Shared `fl-connect.js` heals ride along byte-identical with FreeLattice. **Temperature:** Mom opens Settings and the first thing she sees is the door. + + +## Heal: model choice sticks v0.1 (Hypha walk 4) + +Marker `v-model-choice-sticks-v0`. Test `docs/modules/model-choice-sticks.test.js` prints `SMOKE_OK model choice sticks alpha v0.1`. + +- FreeLattice's Connect now records a tapped model as the person's own choice, so FreeLattice's automatic picker leaves it alone. The Tree has no automatic picker: the remembered entry (`fl_alpha_local_mind`) is the choice, and the test proves it survives a reload, Settings and a Find at the same door, and that the thread request uses it. +- Shared `fl-connect.js` rides along byte-identical: `markUserChoice` (a quiet no-op on the Tree), and a helped Bridge with no mind behind it is named honestly and not saved. +- A Find through a different door (Bridge gone, direct 11434 answers) is healed by the Tree any-model paste (same-family keep). + +**Temperature:** green braided gold, the second cup stays where she set it down. diff --git a/docs/modules/fl-connect.js b/docs/modules/fl-connect.js index 5317005..2e02eae 100644 --- a/docs/modules/fl-connect.js +++ b/docs/modules/fl-connect.js @@ -5,6 +5,7 @@ // Layer, never delete. Prefer helped Bridge (11435…) before bare 11434. // Port-only loopback: fl_localPort_manual holds "11500" only — never foreign hosts. // Soft leave sw.js. Alpha: fl_alpha_local_mind. Never bare *. +// Marker: v-model-choice-sticks-v0 (a tapped model is the person's own choice; it stays) // ═══════════════════════════════════════════════════════════════ (function (root) { @@ -201,12 +202,18 @@ report.bridge = mh; report.port = mh.port || parseInt(manPort, 10); report.helped = true; - report.base = preferHelpedBridge(report.port) || manBase; + // v-model-choice-sticks-v0: ask the helped Bridge first; save it as the Ollama door + // (fl_ollamaHost, fl_bridgePort) only once a mind really answers behind it. + report.base = (report.port && report.port !== 11434) ? 'http://127.0.0.1:' + report.port : manBase; try { report.models = await tagsAt(report.base); + report.base = preferHelpedBridge(report.port) || report.base; report.ollama = { via: 'manual-bridge', base: report.base }; return report; - } catch (eMB) {} + } catch (eMB) { + report.bridgeNoMind = true; + report.models = []; + } } else if (mh) { report.bridge = mh; report.port = mh.port || parseInt(manPort, 10); @@ -357,6 +364,9 @@ // v-connect-heal-v0.4 item 10: handleLocalToggle reads #localToggle, so check it first try { var lt = document.getElementById('localToggle'); if (lt) lt.checked = true; } catch (eLt) {} if (typeof root.handleLocalToggle === 'function') root.handleLocalToggle(true); + // v-model-choice-sticks-v0: the tap is the person's own choice, so the automatic + // picker leaves it alone on tab changes, reloads and Settings. + markUserChoice(name, 'ollama'); if (typeof root.updateStatus === 'function') root.updateStatus(); if (root.AiSetup && typeof root.AiSetup.updateStatus === 'function') root.AiSetup.updateStatus(); // v-connect-heal-v0.4 item 7: once they chose a mind, the Local AI toast stops asking @@ -366,6 +376,29 @@ return { name: name, base: base }; } + // v-model-choice-sticks-v0: FreeLattice keeps one active model (FLActiveModel). A model the + // person tapped is recorded with source 'user' and as their preferred text (or vision) model. + // The Tree has neither helper, so this is a quiet no-op there (it keeps entry.model instead). + function markUserChoice(name, provider) { + if (!name) return false; + var marked = false; + try { + if (root.FLActiveModel && typeof root.FLActiveModel.set === 'function') { + root.FLActiveModel.set(String(name), provider || 'ollama', 'user'); + marked = true; + } + } catch (e0) {} + try { + var auto = root.FLAutoModel; + if (auto && typeof auto.setPreferred === 'function') { + var kind = (typeof auto.isVision === 'function' && auto.isVision(String(name))) ? 'vision' : 'text'; + auto.setPreferred(kind, String(name)); + if (typeof auto.invalidateCache === 'function') auto.invalidateCache(); + } + } catch (e1) {} + return marked; + } + function open() { try { if (!isAlpha() && typeof root.switchTab === 'function') { root.switchTab('connect'); return true; } @@ -539,7 +572,12 @@ if (report.manualReject) html += '

' + report.manualReject + '

'; _manualRejectMsg = ''; // v-connect-heal-v0.4 item 5: shown once, never lingers - if (report.manualQuiet) { + if (report.manualQuiet && report.bridgeNoMind) { + // v-model-choice-sticks-v0: the Bridge did answer; say that, and what is still missing + html += '

Your Bridge answered on ' + (parseInt(report.port, 10) || 'your port') + ', but no mind is running behind it yet. Open Ollama on this computer, then tap Look again.

'; + html += ' '; + html += '
'; + } else if (report.manualQuiet) { html += '

Nothing answered on ' + (report.wanted || 'your port') + '.

'; html += ' '; html += '
'; @@ -690,6 +728,7 @@ hasLocalMind: hasLocalMind, hasCloudMind: hasCloudMind, remember: remember, + markUserChoice: markUserChoice, // v-model-choice-sticks-v0 open: open, mount: mount, unmount: unmount, diff --git a/docs/modules/model-choice-sticks.test.js b/docs/modules/model-choice-sticks.test.js new file mode 100644 index 0000000..8ddce3c --- /dev/null +++ b/docs/modules/model-choice-sticks.test.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// v-model-choice-sticks-v0 (Alpha side): a model tapped in Connect stays the Tree's model +// across a reload, Settings (the remembered entry) and a Find at the same door, and the +// thread request uses it. On the Tree the remembered entry (fl_alpha_local_mind) IS the +// choice; there is no automatic picker. This guards that, and proves the shared +// markUserChoice is a quiet no-op here. fl-connect.js is byte-identical with FreeLattice. +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const flc = fs.readFileSync(path.join(__dirname, 'fl-connect.js'), 'utf8'); +const lmp = fs.readFileSync(path.join(__dirname, 'local-mind-probe.js'), 'utf8'); +const gt = fs.readFileSync(path.join(__dirname, 'garden-thread.js'), 'utf8'); +const MODELS = [{ name: 'llama3.2:latest' }, { name: 'qwen2.5:latest' }]; +const flush = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); }; + +function mkStore(init) { + const s = Object.assign({}, init || {}); + return { s, ls: { getItem: (k) => (k in s ? s[k] : null), setItem: (k, v) => { s[k] = String(v); }, removeItem: (k) => { delete s[k]; } } }; +} +function el(tag) { + const on = {}; + return { + tagName: String(tag || 'div').toUpperCase(), style: {}, dataset: {}, textContent: '', children: [], firstChild: null, attrs: {}, + classList: { add() {}, remove() {}, contains() { return false; } }, + setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] || null; }, removeAttribute() {}, + addEventListener(t, fn) { (on[t] = on[t] || []).push(fn); }, click() { (on.click || []).forEach((fn) => fn({ currentTarget: this })); }, + appendChild(c) { this.children.push(c); this.firstChild = this.children[0]; return c; }, + removeChild(c) { this.children = this.children.filter((x) => x !== c); this.firstChild = this.children[0] || null; }, + querySelector() { return null; } + }; +} +function page(store, posts) { + const ctx = { + localStorage: store.ls, + location: { hostname: 'thelatticetree.com', protocol: 'https:', hash: '', search: '', href: 'https://thelatticetree.com/' }, + document: { + hidden: false, + documentElement: { getAttribute: () => 'garden', classList: { add() {}, remove() {}, contains() { return false; } } }, + getElementById: () => null, querySelectorAll: () => [], querySelector: () => null, + createElement: (t) => el(t), head: { appendChild() {} }, + body: { contains: () => true, appendChild() {}, removeChild() {}, classList: { add() {}, remove() {} } }, + addEventListener() {}, removeEventListener() {} + }, + fetch: async (url, opts) => { + url = String(url); + assert.ok(/^http:\/\/127\.0\.0\.1:\d+\//.test(url), 'only 127.0.0.1: ' + url); + if (opts && opts.method === 'POST') { + posts.push({ url, body: JSON.parse(opts.body) }); + return { ok: true, status: 200, json: async () => ({ message: { content: 'hello' } }) }; + } + if (/127\.0\.0\.1:11434\/api\/tags$/.test(url)) return { ok: true, status: 200, json: async () => ({ models: MODELS }) }; + throw new Error('quiet'); + }, + setTimeout: (fn, ms) => (ms && ms > 100 ? 1 : setTimeout(fn, 0)), clearTimeout() {}, + AbortController, navigator: { userAgent: 't' }, console: { log() {}, warn() {}, error() {} }, URL, + CustomEvent: function (t, o) { this.type = t; this.detail = o && o.detail; }, + dispatchEvent() {}, addEventListener() {}, + JSON, Promise, Date, Math, String, Object, Array, Number, Error + }; + ctx.window = ctx; + vm.runInNewContext(flc, ctx); + vm.runInNewContext(lmp, ctx); + vm.runInNewContext(gt, ctx); + return ctx; +} +function connectHost() { + const host = { hidden: false, lists: [], _html: '', scrollIntoView() {} }; + Object.defineProperty(host, 'innerHTML', { + get() { return this._html; }, + set(v) { + this._html = String(v); this.lists = []; + const re = /class="flc-models"[^>]*?data-flc-names="([^"]*)"/g; let m; + while ((m = re.exec(this._html))) { const l = el('div'); l.attrs['data-flc-names'] = m[1].replace(/"/g, '"').replace(/&/g, '&'); this.lists.push(l); } + } + }); + host.querySelectorAll = (sel) => (sel === '.flc-models' ? host.lists : []); + return host; +} + +(async () => { + assert.ok(/v-model-choice-sticks-v0/.test(flc), 'marker in the shared core'); + const store = mkStore(); + const posts = []; + let p = page(store, posts); + + // Connect: tap the second model + const host = connectHost(); + p.FlConnect.mount(host); + await flush(); + const btns = [].concat(...host.lists.map((l) => l.children)); + assert.deepStrictEqual(btns.map((b) => b.textContent), ['llama3.2:latest', 'qwen2.5:latest'], 'both models listed'); + btns[1].click(); + await flush(); + p.FlConnect.unmount(); + assert.strictEqual(p.LocalMindProbe.getRemembered().model, 'qwen2.5:latest', 'Tree remembers the tapped model'); + assert.strictEqual(p.FlConnect.markUserChoice('qwen2.5:latest'), false, 'markUserChoice is a no-op on the Tree'); + + // Reload, then Settings reads the remembered entry, then Find at the same door + p = page(store, posts); + assert.strictEqual(p.LocalMindProbe.getRemembered().model, 'qwen2.5:latest', 'reload keeps it'); + const found = await p.LocalMindProbe.look(); + const prior = p.LocalMindProbe.getRemembered(); + const entry = p.LocalMindProbe.entryFromFoundList(found.foundList, 'a mind at home', '', prior); + assert.strictEqual(entry.model, 'qwen2.5:latest', 'Find at the same Ollama door keeps the chosen model'); + assert.ok(!entry.modelNote, 'no false "gone" line'); + p.LocalMindProbe.remember(entry); + + // The thread request uses it + await p.GardenThread.sendToMind(p.LocalMindProbe.getRemembered(), [{ role: 'user', content: 'hi' }]); + assert.ok(posts.length >= 1, 'thread posted'); + assert.strictEqual(posts[posts.length - 1].body.model, 'qwen2.5:latest', 'thread request body uses the chosen model'); + + console.log('SMOKE_OK model choice sticks alpha v0.1'); +})().catch((e) => { console.error(e); process.exit(1); }); End of paste. Marker v-model-choice-sticks-v0. Glow eternal. Heart in Spark.