From ca94e0328978d031314e2271432bfa1e0baf5489 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 15 Oct 2025 19:09:01 -0300 Subject: [PATCH] fixed all warnings and the docs --- src/ai-repo-commander.user.js | 199 ++++++++++++++++++++++++---------- 1 file changed, 144 insertions(+), 55 deletions(-) diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index a177b59..c085346 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -765,7 +765,17 @@ return _hash(buf.slice(-2000)); } - // Ordinal among messages that share the same (commandHash, prevCtxHash) + // Hash the text within this message that appears BEFORE the first command block + function _hashIntraMessagePrefix(el) { + const t = (el.textContent || ''); + // Find the first complete @bridge@ block + const m = t.match(/@bridge@[\s\S]*?@end@/m); + const endIdx = m ? t.indexOf(m[0]) : t.length; + // Hash the last 2000 chars before the command block + return _hash(_norm(t.slice(Math.max(0, endIdx - 2000), endIdx))); + } + + // Ordinal among messages that share the same (commandHash, prevCtxHash, intraHash) function _ordinalForKey(el, key) { const list = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))); let n = 0; @@ -776,7 +786,8 @@ // Compute on the fly only if needed const ch = _hashCommand(node); const ph = _hashPrevContext(node); - return `ch:${ch}|ph:${ph}`; + const ih = _hashIntraMessagePrefix(node); + return `ch:${ch}|ph:${ph}|ih:${ih}`; })(); if (nodeKey === key) n++; if (node === el) return n; // 1-based ordinal @@ -802,8 +813,9 @@ // Always use content-based fingerprinting for reliability across reloads const ch = _hashCommand(el); const ph = _hashPrevContext(el); + const ih = _hashIntraMessagePrefix(el); const dh = _hash(_domHint(el)); - const key = `ch:${ch}|ph:${ph}`; + const key = `ch:${ch}|ph:${ph}|ih:${ih}`; const n = _ordinalForKey(el, key); const fingerprint = `${key}|hint:${dh}|n:${n}`; @@ -811,6 +823,7 @@ fingerprint: fingerprint.slice(0, 60) + '...', commandHash: ch, prevContextHash: ph, + intraMessageHash: ih, domHint: dh, ordinal: n }); @@ -818,6 +831,15 @@ return fingerprint; } + // Stable fingerprint: computed once per element, then cached on dataset. + // Prevents drift when the DOM/text changes later. + function getStableFingerprint(el) { + if (el?.dataset?.aiRcStableFp) return el.dataset.aiRcStableFp; + const fp = fingerprintElement(el); + try { if (el && el.dataset) el.dataset.aiRcStableFp = fp; } catch {} + return fp; + } + // ---------------------- Multi-block extraction helpers ---------------------- function extractAllCompleteBlocks(text) { const out = []; @@ -944,17 +966,23 @@ RC_DEBUG?.verbose('Cluster rescan completed', { scanned, deadline: Date.now() >= deadline }); } + // Helper functions for per-subcommand dataset flags + function subDoneKey(i) { return `aiRcSubDone_${i}`; } // i is 1-based + function subEnqKey(i) { return `aiRcSubEnq_${i}`; } // i is 1-based + // Tiny badge on the message showing how many got queued function attachQueueBadge(el, count) { - if (el.querySelector('.ai-rc-queue-badge')) return; - const badge = document.createElement('span'); - badge.className = 'ai-rc-queue-badge'; - badge.textContent = `${count} command${count>1?'s':''} queued`; - badge.style.cssText = ` + let badge = el.querySelector('.ai-rc-queue-badge'); + if (!badge) { + badge = document.createElement('span'); + badge.className = 'ai-rc-queue-badge'; + badge.style.cssText = ` display:inline-block; padding:2px 6px; margin:4px 0; background:#3b82f6; color:#fff; border-radius:4px; font:11px ui-monospace, monospace;`; - el.insertBefore(badge, el.firstChild); + el.insertBefore(badge, el.firstChild); + } + badge.textContent = `${count} command${count>1?'s':''} queued`; } // Wait until it's safe to paste/submit @@ -1173,7 +1201,7 @@ * @param {string|number} [suffix] */ hasElement(el, suffix = '') { - let fp = fingerprintElement(el); + let fp = getStableFingerprint(el); if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; const result = this.session.has(fp) || (fp in this.cache); @@ -1193,12 +1221,15 @@ * @param {string|number} [suffix] */ markElement(el, suffix = '') { - let fp = fingerprintElement(el); + let fp = getStableFingerprint(el); if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; this.session.add(fp); this.cache[fp] = Date.now(); this._save(); + // Also set hard per-subcommand flag on element (bullet-proof local dedupe) + try { if (el && el.dataset && suffix) el.dataset[subDoneKey(Number(suffix))] = '1'; } catch {} + RC_DEBUG?.verbose('Marked element as processed', { fingerprint: fp.slice(0, 60) + '...' }); @@ -2172,7 +2203,12 @@ // Trigger cluster rescan for chainable commands try { if (shouldTriggerClusterRescan(sourceElement, command.action)) { - await scheduleClusterRescan(sourceElement); + if (!sourceElement.dataset.aiRcClusterCoolUntil || Date.now() > Number(sourceElement.dataset.aiRcClusterCoolUntil)) { + sourceElement.dataset.aiRcClusterCoolUntil = String(Date.now() + 1500); + await scheduleClusterRescan(sourceElement); + } else { + RC_DEBUG?.verbose('Cluster rescan suppressed by cooldown'); + } } } catch (e) { RC_DEBUG?.verbose('Cluster rescan failed', { error: String(e) }); @@ -2541,62 +2577,99 @@ messages.forEach((el) => { if (!this.isAssistantMessage(el)) return; - if (el.dataset.aiRcProcessed) return; + // Allow re-scan of already-processed messages to catch *new* blocks appended later const hits = findAllCommandsInMessage(el); if (!hits.length) return; - if (hits.length === 1) { + const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + + // Count how many sub-commands we have already marked for this element + // Prefer element flags (local), fallback to history (persistent) + let already = 0; + for (let i = 0; i < capped.length; i++) { + const idx1 = i + 1; + const done = el?.dataset?.[subDoneKey(idx1)] === '1' || this.history.hasElement(el, idx1); + if (done) already++; + } + + // Case A: first time seeing this message (no aiRcProcessed yet) + if (!el.dataset.aiRcProcessed) { el.dataset.aiRcProcessed = '1'; - if (this.history.hasElement(el, 1)) { - attachRunAgainUI(el, () => this.trackMessage(el, hits[0].text, this.getReadableMessageId(el))); + + // If only one block, keep fast path + if (capped.length === 1) { + if (already > 0) { + // Already executed, add Run Again button + attachRunAgainUI(el, () => this.trackMessage(el, capped[0].text, this.getReadableMessageId(el))); + skipped++; + return; + } + this.history.markElement(el, 1); + this.trackMessage(el, capped[0].text, this.getReadableMessageId(el)); + found++; return; } - this.history.markElement(el, 1); - this.trackMessage(el, hits[0].text, this.getReadableMessageId(el)); - return; - } - const withinColdStart = Date.now() < this.coldStartUntil; - const alreadyAll = hits.every((_, i) => this.history.hasElement(el, i + 1)); + // Check if within cold start or all already executed + const withinColdStart = Date.now() < this.coldStartUntil; + const alreadyAll = (already === capped.length); - RC_DEBUG?.trace('Evaluating message', { - withinColdStart, - alreadyAll, - commandCount: hits.length - }); + if (withinColdStart || alreadyAll) { + RC_DEBUG?.verbose( + 'Skipping command(s) - ' + + (withinColdStart ? 'page load (cold start)' : 'already executed in this conversation'), + { fingerprint: fingerprintElement(el).slice(0, 40) + '...', commandCount: hits.length } + ); - // Skip if cold start or already processed (but DON'T mark new ones in history during cold start) - if (withinColdStart || alreadyAll) { - el.dataset.aiRcProcessed = '1'; + attachRunAgainPerCommand(el, capped, (idx) => { + el.dataset.aiRcProcessed = '1'; + const hit2 = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + const h = hit2[idx]; + if (h) this.enqueueCommand(el, h, idx); + }); + skipped += capped.length; + return; + } - RC_DEBUG?.verbose( - 'Skipping command(s) - ' + - (withinColdStart ? 'page load (cold start)' : 'already executed in this conversation'), - { fingerprint: fingerprintElement(el).slice(0, 40) + '...', commandCount: hits.length } - ); - - attachRunAgainPerCommand(el, hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE), (idx) => { - el.dataset.aiRcProcessed = '1'; - const hit2 = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - const h = hit2[idx]; - if (h) this.enqueueCommand(el, h, idx); + // Multi-block: mark & enqueue all we see now + attachQueueBadge(el, capped.length); + capped.forEach((hit, idx) => { + const subIdx = idx + 1; + const enqKey = subEnqKey(subIdx); + if (el?.dataset?.[enqKey] === '1' || el?.dataset?.[subDoneKey(subIdx)] === '1') return; + try { if (el && el.dataset) el.dataset[enqKey] = '1'; } catch {} + this.history.markElement(el, subIdx); + this.enqueueCommand(el, hit, idx); }); - skipped += hits.length; + found += capped.length; return; } - // New message that hasn't been executed → auto-execute once - el.dataset.aiRcProcessed = '1'; - const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - attachQueueBadge(el, capped.length); + // Case B: message was already processed; enqueue only the *new* ones + if (already < capped.length) { + const newlyAdded = capped.slice(already); + RC_DEBUG?.info('Detected new command blocks in already-processed message', { + alreadyCount: already, + newCount: newlyAdded.length, + totalCount: capped.length + }); - capped.forEach((hit, idx) => { - // mark each sub-command immediately to avoid re-exec on reloads - this.history.markElement(el, idx + 1); - this.enqueueCommand(el, hit, idx); - }); - found += capped.length; + const existingQueued = parseInt(el.dataset.aiRcQueued || '0', 10) || 0; + const total = existingQueued + newlyAdded.length; + attachQueueBadge(el, total); + + newlyAdded.forEach((hit, idx) => { + const subIdx = already + idx + 1; // 1-based + const enqKey = subEnqKey(subIdx); + if (el?.dataset?.[enqKey] === '1' || el?.dataset?.[subDoneKey(subIdx)] === '1') return; + try { if (el && el.dataset) el.dataset[enqKey] = '1'; } catch {} + this.history.markElement(el, subIdx); // also sets SubDone via patch #2 + this.enqueueCommand(el, hit, subIdx - 1); + }); + el.dataset.aiRcQueued = String(total); + found += newlyAdded.length; + } }); if (skipped) RC_DEBUG?.info(`Skipped ${skipped} command(s) - Run Again buttons added`); @@ -2605,7 +2678,16 @@ enqueueCommand(element, hit, idx) { const messageId = this.getReadableMessageId(element); - const subId = `${messageId}#${idx + 1}`; + const subIndex1 = (idx + 1); + const subId = `${messageId}#${subIndex1}`; + + // Hard guard: never enqueue twice + const enqKey = subEnqKey(subIndex1); + if (element?.dataset?.[enqKey] === '1' && element?.dataset?.[subDoneKey(subIndex1)] === '1') { + RC_DEBUG?.verbose('Skip enqueue (already done)', { subIndex1 }); + return; + } + try { if (element && element.dataset) element.dataset[enqKey] = '1'; } catch {} execQueue.push(async () => { // Micro-settle: wait for text to stabilize before parsing @@ -2613,8 +2695,8 @@ const blockElement = hit.blockElement; if (blockElement) { let lastText = blockElement.textContent || ''; - const maxWait = 400; - const checkInterval = 80; + const maxWait = 700; + const checkInterval = 70; const startTime = Date.now(); while (Date.now() - startTime < maxWait) { @@ -2973,6 +3055,13 @@ this.updateState(messageId, COMMAND_STATES.COMPLETE); + // Mark as done on element (belt-and-suspenders against fingerprint drift) + try { + const m = /#(\d+)$/.exec(messageId); + const subIndex1 = m ? Number(m[1]) : 1; + if (message?.element?.dataset) message.element.dataset[subDoneKey(subIndex1)] = '1'; + } catch {} + } catch (error) { const duration = Date.now() - started; RC_DEBUG?.error(`Command processing error: ${error.message}`, { messageId, duration });