@7ots/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +140 -0
- package/LICENSE +21 -0
- package/README.md +655 -0
- package/brand/apple-touch-icon.png +0 -0
- package/brand/favicon.svg +4 -0
- package/brand/icon-512.png +0 -0
- package/brand/mark-mono.svg +3 -0
- package/brand/mark.svg +4 -0
- package/brand/og-es.png +0 -0
- package/brand/og-pt.png +0 -0
- package/brand/og.png +0 -0
- package/brand/ots.json +11 -0
- package/brand/tokens.css +59 -0
- package/brand/wordmark.svg +5 -0
- package/cli/7ots.mjs +400 -0
- package/cli/lib/brain.mjs +143 -0
- package/cli/lib/config.mjs +70 -0
- package/cli/lib/hooks.mjs +151 -0
- package/cli/lib/i18n.mjs +480 -0
- package/cli/lib/identity.mjs +51 -0
- package/cli/lib/install.mjs +79 -0
- package/cli/lib/lines.mjs +73 -0
- package/cli/lib/meet.mjs +166 -0
- package/cli/lib/open.mjs +19 -0
- package/cli/lib/orquesta.mjs +84 -0
- package/cli/lib/paths.mjs +49 -0
- package/cli/lib/pet-core.mjs +184 -0
- package/cli/lib/pet-server.mjs +296 -0
- package/cli/lib/prompts.mjs +75 -0
- package/cli/lib/terminal.mjs +145 -0
- package/cli/lib/ui.mjs +234 -0
- package/cli/lib/wizard.mjs +279 -0
- package/cli/pet/electron/main.cjs +86 -0
- package/cli/pet/pet.html +404 -0
- package/dist/7ots.esm.js +627 -0
- package/dist/7ots.esm.js.map +7 -0
- package/dist/7ots.iife.js +627 -0
- package/dist/7ots.iife.js.map +7 -0
- package/llms.txt +57 -0
- package/package.json +73 -0
- package/server/admin.mjs +213 -0
- package/server/apuchat-relay.mjs +170 -0
- package/server/channels/agent.mjs +136 -0
- package/server/channels/apuchat.mjs +364 -0
- package/server/channels/apumail.mjs +211 -0
- package/server/channels/index.mjs +69 -0
- package/server/contact.mjs +151 -0
- package/server/demo.mjs +154 -0
- package/server/i18n.mjs +54 -0
- package/server/identity.mjs +108 -0
- package/server/llm.mjs +413 -0
- package/server/platform/auth.mjs +290 -0
- package/server/platform/crypto.mjs +80 -0
- package/server/platform/db.mjs +130 -0
- package/server/platform/routes.mjs +388 -0
- package/server/platform/runtime.mjs +180 -0
- package/server/platform/store.mjs +331 -0
- package/server/sdk.mjs +125 -0
- package/server/server.mjs +456 -0
- package/server/settings.mjs +177 -0
- package/server/tts.mjs +186 -0
- package/skills/7ots/SKILL.md +59 -0
- package/src/actions/ActionRegistry.js +254 -0
- package/src/actions/PageTools.js +270 -0
- package/src/actions/builtins.js +565 -0
- package/src/auth/AuthManager.js +128 -0
- package/src/avatar/AvatarStage.js +473 -0
- package/src/character/AvatarEditor.js +829 -0
- package/src/character/Character.js +1191 -0
- package/src/character/motion.js +794 -0
- package/src/character/parts.js +559 -0
- package/src/context/ContextManager.js +472 -0
- package/src/core/AgentBrain.js +341 -0
- package/src/core/AgentWidget.js +1059 -0
- package/src/core/EventBus.js +61 -0
- package/src/core/Proactivity.js +363 -0
- package/src/core/storage.js +35 -0
- package/src/i18n/index.js +193 -0
- package/src/i18n/messages/character.js +543 -0
- package/src/i18n/messages/identity.js +141 -0
- package/src/i18n/messages/platform.js +114 -0
- package/src/i18n/messages/server.js +630 -0
- package/src/i18n/messages/widget.js +362 -0
- package/src/identity/ContactCard.js +351 -0
- package/src/identity/Face.js +148 -0
- package/src/identity/random.js +112 -0
- package/src/identity/schema.js +262 -0
- package/src/index.js +117 -0
- package/src/integrations/apuchat.js +212 -0
- package/src/integrations/apumail.js +88 -0
- package/src/llm/ProxyLLM.js +87 -0
- package/src/mcp/McpClient.js +153 -0
- package/src/ui/Companion.js +498 -0
- package/src/ui/UIManager.js +425 -0
- package/src/ui/VirtualPointer.js +746 -0
- package/src/ui/markdown.js +94 -0
- package/src/voice/VoiceEngine.js +227 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextManager — consciencia del contexto (DOM Awareness).
|
|
3
|
+
*
|
|
4
|
+
* Responsabilidades:
|
|
5
|
+
* 1. Escanear el DOM y convertirlo en un contexto compacto (JSON + Markdown) que el
|
|
6
|
+
* LLM entienda: URL, estructura, sección actual, elementos interactivos, formularios.
|
|
7
|
+
* 2. Asignar "refs" estables (e1, e2…) a los elementos interactivos para que el LLM
|
|
8
|
+
* actúe sobre ellos sin inventar selectores CSS frágiles.
|
|
9
|
+
* 3. Vigilar la página: MutationObserver (cambios del DOM, alertas, SPA), scroll
|
|
10
|
+
* (sección visible) e historial (pushState/replaceState/popstate/hashchange).
|
|
11
|
+
*
|
|
12
|
+
* Privacidad:
|
|
13
|
+
* - Nunca se envían valores de inputs password, tarjetas (autocomplete cc-*),
|
|
14
|
+
* ni de nada marcado con [data-ots-private] o `privateSelectors`.
|
|
15
|
+
* - Lo marcado con [data-ots-ignore] (y el propio widget) no existe para el agente.
|
|
16
|
+
* - El texto de la página es CONTENIDO NO CONFIABLE: el system prompt se lo dice al
|
|
17
|
+
* LLM para mitigar prompt injection desde el sitio (p. ej. reseñas de usuarios).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const INTERACTIVE = [
|
|
21
|
+
'a[href]', 'button', 'input:not([type=hidden])', 'select', 'textarea', 'summary',
|
|
22
|
+
'[role=button]', '[role=link]', '[role=tab]', '[role=menuitem]', '[role=checkbox]',
|
|
23
|
+
'[role=switch]', '[contenteditable=""]', '[contenteditable=true]',
|
|
24
|
+
].join(',');
|
|
25
|
+
|
|
26
|
+
const SECTION_CANDIDATES = [
|
|
27
|
+
'[data-ots-section]', 'section', 'article', 'main > [id]', '[role=region]', 'form[id]',
|
|
28
|
+
].join(',');
|
|
29
|
+
|
|
30
|
+
const ALERT_SELECTOR = '[role=alert], [aria-live=assertive], [aria-invalid=true], .error, .alert-danger, .is-invalid';
|
|
31
|
+
|
|
32
|
+
const ALWAYS_PRIVATE = [
|
|
33
|
+
'input[type=password]', '[autocomplete^=cc-]', '[data-ots-private]', '[data-ots-private] *',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export class ContextManager {
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} opt
|
|
39
|
+
* @param {import('../core/EventBus.js').EventBus} opt.bus
|
|
40
|
+
* @param {string[]} [opt.ignoreSelectors] Zonas invisibles para el agente.
|
|
41
|
+
* @param {string[]} [opt.privateSelectors] Zonas cuyo contenido/valor no sale del navegador.
|
|
42
|
+
* @param {string} [opt.sectionSelector] Override de qué cuenta como "sección".
|
|
43
|
+
* @param {number} [opt.maxElements=80]
|
|
44
|
+
* @param {number} [opt.maxChars=9000] Tamaño máximo del markdown de contexto.
|
|
45
|
+
* @param {() => object} [opt.extra] Contexto adicional del desarrollador (carrito, plan…).
|
|
46
|
+
*/
|
|
47
|
+
constructor({
|
|
48
|
+
bus,
|
|
49
|
+
ignoreSelectors = [],
|
|
50
|
+
privateSelectors = [],
|
|
51
|
+
sectionSelector = SECTION_CANDIDATES,
|
|
52
|
+
maxElements = 80,
|
|
53
|
+
maxChars = 9000,
|
|
54
|
+
extra = null,
|
|
55
|
+
} = {}) {
|
|
56
|
+
this.bus = bus;
|
|
57
|
+
this.ignoreSel = ['[data-ots-root]', '[data-ots-ignore]', 'script', 'style', 'noscript', 'template', ...ignoreSelectors].join(',');
|
|
58
|
+
this.privateSel = [...ALWAYS_PRIVATE, ...privateSelectors].join(',');
|
|
59
|
+
this.sectionSel = sectionSelector;
|
|
60
|
+
this.maxElements = maxElements;
|
|
61
|
+
this.maxChars = maxChars;
|
|
62
|
+
this.extra = extra;
|
|
63
|
+
|
|
64
|
+
/** ref -> WeakRef<Element> ; el -> ref (para que un elemento conserve su ref) */
|
|
65
|
+
this._refs = new Map();
|
|
66
|
+
this._refOf = new WeakMap();
|
|
67
|
+
this._seq = 0;
|
|
68
|
+
|
|
69
|
+
this.currentSection = null;
|
|
70
|
+
this._lastUrl = location.href;
|
|
71
|
+
this._cleanup = [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ───────────────────────────── Observación ─────────────────────────────
|
|
75
|
+
|
|
76
|
+
start() {
|
|
77
|
+
// 1) Cambios de ruta (SPA): parcheamos history una sola vez por página.
|
|
78
|
+
patchHistoryOnce();
|
|
79
|
+
const onRoute = () => {
|
|
80
|
+
if (location.href === this._lastUrl) return;
|
|
81
|
+
const prev = this._lastUrl;
|
|
82
|
+
this._lastUrl = location.href;
|
|
83
|
+
this.bus.emit('context:route', { url: location.href, prev });
|
|
84
|
+
queueMicrotask(() => this._detectSection());
|
|
85
|
+
};
|
|
86
|
+
for (const ev of ['ots:locationchange', 'popstate', 'hashchange']) {
|
|
87
|
+
window.addEventListener(ev, onRoute);
|
|
88
|
+
this._cleanup.push(() => window.removeEventListener(ev, onRoute));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2) Sección visible: scroll con throttle.
|
|
92
|
+
let ticking = false;
|
|
93
|
+
const onScroll = () => {
|
|
94
|
+
if (ticking) return;
|
|
95
|
+
ticking = true;
|
|
96
|
+
setTimeout(() => { ticking = false; this._detectSection(); }, 250);
|
|
97
|
+
};
|
|
98
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
99
|
+
this._cleanup.push(() => window.removeEventListener('scroll', onScroll));
|
|
100
|
+
|
|
101
|
+
// 3) MutationObserver: nuevas secciones (SPA), modales del sitio y alertas/errores.
|
|
102
|
+
let pending = { added: 0, removed: 0, alerts: new Set() };
|
|
103
|
+
let flushTimer = null;
|
|
104
|
+
const flush = () => {
|
|
105
|
+
flushTimer = null;
|
|
106
|
+
const { added, removed, alerts } = pending;
|
|
107
|
+
pending = { added: 0, removed: 0, alerts: new Set() };
|
|
108
|
+
if (added || removed) this.bus.emit('context:mutation', { added, removed });
|
|
109
|
+
for (const text of alerts) this.bus.emit('context:alert', { text });
|
|
110
|
+
this._detectSection();
|
|
111
|
+
};
|
|
112
|
+
const mo = new MutationObserver((records) => {
|
|
113
|
+
for (const r of records) {
|
|
114
|
+
const target = r.target.nodeType === 1 ? r.target : r.target.parentElement;
|
|
115
|
+
if (target && this._isIgnored(target)) continue;
|
|
116
|
+
for (const n of r.addedNodes) {
|
|
117
|
+
if (n.nodeType !== 1 || this._isIgnored(n)) continue;
|
|
118
|
+
pending.added++;
|
|
119
|
+
const alerts = n.matches?.(ALERT_SELECTOR) ? [n] : [...(n.querySelectorAll?.(ALERT_SELECTOR) || [])];
|
|
120
|
+
for (const a of alerts) {
|
|
121
|
+
const t = cleanText(a.innerText || a.getAttribute('aria-label') || '');
|
|
122
|
+
if (t) pending.alerts.add(t.slice(0, 200));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
pending.removed += r.removedNodes.length;
|
|
126
|
+
if (r.type === 'attributes' && target?.matches?.(ALERT_SELECTOR)) {
|
|
127
|
+
const t = cleanText(target.innerText || '');
|
|
128
|
+
if (t) pending.alerts.add(t.slice(0, 200));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!flushTimer) flushTimer = setTimeout(flush, 400);
|
|
132
|
+
});
|
|
133
|
+
mo.observe(document.body, {
|
|
134
|
+
childList: true,
|
|
135
|
+
subtree: true,
|
|
136
|
+
attributes: true,
|
|
137
|
+
attributeFilter: ['aria-invalid', 'role', 'class', 'hidden', 'open'],
|
|
138
|
+
});
|
|
139
|
+
this._cleanup.push(() => mo.disconnect());
|
|
140
|
+
|
|
141
|
+
this._detectSection();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
stop() {
|
|
145
|
+
this._cleanup.forEach((fn) => fn());
|
|
146
|
+
this._cleanup = [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** La sección que contiene la línea de lectura (40% del alto del viewport). */
|
|
150
|
+
_detectSection() {
|
|
151
|
+
const y = window.innerHeight * 0.4;
|
|
152
|
+
let best = null;
|
|
153
|
+
let bestArea = Infinity;
|
|
154
|
+
for (const el of document.querySelectorAll(this.sectionSel)) {
|
|
155
|
+
if (this._isIgnored(el)) continue;
|
|
156
|
+
const r = el.getBoundingClientRect();
|
|
157
|
+
if (r.top <= y && r.bottom >= y && r.height > 0) {
|
|
158
|
+
const area = r.height * r.width;
|
|
159
|
+
if (area < bestArea) { best = el; bestArea = area; } // la más específica gana
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const info = best ? { id: best.id || null, title: sectionTitle(best), el: best } : null;
|
|
163
|
+
const changed = (info?.el || null) !== (this.currentSection?.el || null);
|
|
164
|
+
this.currentSection = info;
|
|
165
|
+
if (changed && info) this.bus.emit('context:section', { id: info.id, title: info.title });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ───────────────────────────── Refs ─────────────────────────────
|
|
169
|
+
|
|
170
|
+
refFor(el) {
|
|
171
|
+
let ref = this._refOf.get(el);
|
|
172
|
+
if (!ref) {
|
|
173
|
+
ref = `e${++this._seq}`;
|
|
174
|
+
this._refOf.set(el, ref);
|
|
175
|
+
}
|
|
176
|
+
this._refs.set(ref, new WeakRef(el));
|
|
177
|
+
return ref;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resuelve un objetivo que viene del LLM: "e12", {ref:"e12"}, {selector:"#id"} o un selector.
|
|
182
|
+
* @returns {Element|null}
|
|
183
|
+
*/
|
|
184
|
+
resolve(target) {
|
|
185
|
+
if (!target) return null;
|
|
186
|
+
if (typeof target === 'object') {
|
|
187
|
+
if (target.ref) return this.resolve(target.ref);
|
|
188
|
+
if (target.selector) return this.resolve(target.selector);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const s = String(target).trim();
|
|
192
|
+
if (/^e\d+$/.test(s)) {
|
|
193
|
+
const el = this._refs.get(s)?.deref();
|
|
194
|
+
return el && el.isConnected ? el : null;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const el = document.querySelector(s);
|
|
198
|
+
return el && !this._isIgnored(el) ? el : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null; // selector inválido
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ───────────────────────────── Snapshot ─────────────────────────────
|
|
205
|
+
|
|
206
|
+
/** Contexto estructurado de la página, listo para serializar. */
|
|
207
|
+
snapshot() {
|
|
208
|
+
const section = this.currentSection;
|
|
209
|
+
const viewport = { top: 0, bottom: window.innerHeight };
|
|
210
|
+
|
|
211
|
+
// Elementos interactivos: primero los visibles en pantalla, luego los de la sección actual,
|
|
212
|
+
// luego el resto, hasta maxElements.
|
|
213
|
+
const all = [...document.querySelectorAll(INTERACTIVE)].filter((el) => !this._isIgnored(el) && isVisible(el));
|
|
214
|
+
const score = (el) => {
|
|
215
|
+
const r = el.getBoundingClientRect();
|
|
216
|
+
if (r.bottom >= viewport.top && r.top <= viewport.bottom) return 0;
|
|
217
|
+
if (section?.el?.contains(el)) return 1;
|
|
218
|
+
return 2;
|
|
219
|
+
};
|
|
220
|
+
const ranked = all
|
|
221
|
+
.map((el, i) => ({ el, s: score(el), i }))
|
|
222
|
+
.sort((a, b) => a.s - b.s || a.i - b.i)
|
|
223
|
+
.slice(0, this.maxElements)
|
|
224
|
+
.sort((a, b) => a.i - b.i) // devolver en orden de documento
|
|
225
|
+
.map(({ el, s }) => ({ ...this._describe(el), inView: s === 0 }));
|
|
226
|
+
|
|
227
|
+
const forms = [...document.forms]
|
|
228
|
+
.filter((f) => !this._isIgnored(f) && isVisible(f))
|
|
229
|
+
.slice(0, 8)
|
|
230
|
+
.map((f) => ({
|
|
231
|
+
ref: this.refFor(f),
|
|
232
|
+
name: f.getAttribute('aria-label') || f.getAttribute('name') || f.id || sectionTitle(f) || null,
|
|
233
|
+
fields: [...f.elements]
|
|
234
|
+
.filter((el) => el.matches(INTERACTIVE) && !this._isIgnored(el) && el.type !== 'hidden')
|
|
235
|
+
.slice(0, 25)
|
|
236
|
+
.map((el) => this._describe(el)),
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
const headings = [...document.querySelectorAll('h1, h2, h3')]
|
|
240
|
+
.filter((h) => !this._isIgnored(h) && isVisible(h))
|
|
241
|
+
.slice(0, 40)
|
|
242
|
+
.map((h) => ({
|
|
243
|
+
level: Number(h.tagName[1]),
|
|
244
|
+
text: cleanText(h.innerText).slice(0, 100),
|
|
245
|
+
id: h.id || h.closest('[id]')?.id || null,
|
|
246
|
+
current: !!section?.el?.contains(h),
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
const main = document.querySelector('main, [role=main]') || document.body;
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
url: location.href,
|
|
253
|
+
path: location.pathname + location.search + location.hash,
|
|
254
|
+
title: document.title,
|
|
255
|
+
lang: document.documentElement.lang || navigator.language,
|
|
256
|
+
description: document.querySelector('meta[name=description]')?.content || null,
|
|
257
|
+
scroll: {
|
|
258
|
+
percent: Math.round((window.scrollY / Math.max(1, document.documentElement.scrollHeight - innerHeight)) * 100),
|
|
259
|
+
},
|
|
260
|
+
section: section ? { id: section.id, title: section.title, text: this._text(section.el, 2500) } : null,
|
|
261
|
+
headings,
|
|
262
|
+
elements: ranked,
|
|
263
|
+
forms,
|
|
264
|
+
pageText: section ? null : this._text(main, 2500),
|
|
265
|
+
selection: this._selection(),
|
|
266
|
+
extra: safeCall(this.extra),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Markdown compacto: es lo que realmente ve el LLM en cada turno. */
|
|
271
|
+
toMarkdown(snap = this.snapshot()) {
|
|
272
|
+
const L = [];
|
|
273
|
+
L.push(`# Página: ${snap.title || '(sin título)'}`);
|
|
274
|
+
L.push(`URL: ${snap.url}`);
|
|
275
|
+
if (snap.description) L.push(`Descripción: ${snap.description}`);
|
|
276
|
+
L.push(`Idioma: ${snap.lang} · Scroll: ${snap.scroll.percent}%`);
|
|
277
|
+
if (snap.extra) L.push(`Datos del sitio: ${JSON.stringify(snap.extra).slice(0, 800)}`);
|
|
278
|
+
|
|
279
|
+
if (snap.headings.length) {
|
|
280
|
+
L.push('', '## Estructura');
|
|
281
|
+
for (const h of snap.headings) {
|
|
282
|
+
L.push(`${' '.repeat(h.level - 1)}- ${h.text}${h.id ? ` (#${h.id})` : ''}${h.current ? ' ← el usuario está aquí' : ''}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (snap.section) {
|
|
287
|
+
L.push('', `## Sección actual: ${snap.section.title || snap.section.id || '(sin nombre)'}`, snap.section.text);
|
|
288
|
+
} else if (snap.pageText) {
|
|
289
|
+
L.push('', '## Contenido visible', snap.pageText);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (snap.selection) L.push('', `## Texto seleccionado por el usuario`, snap.selection);
|
|
293
|
+
|
|
294
|
+
if (snap.elements.length) {
|
|
295
|
+
L.push('', '## Elementos interactivos (usa el ref para actuar; * = visible en pantalla)');
|
|
296
|
+
for (const e of snap.elements) L.push(`[${e.ref}]${e.inView ? '*' : ''} ${fmtEl(e)}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (snap.forms.length) {
|
|
300
|
+
L.push('', '## Formularios');
|
|
301
|
+
for (const f of snap.forms) {
|
|
302
|
+
L.push(`[${f.ref}] form "${f.name || 'sin nombre'}": ${f.fields.map((x) => `${x.ref} ${x.label || x.name || x.kind}${x.required ? '*' : ''}`).join(', ')}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let md = L.join('\n');
|
|
307
|
+
if (md.length > this.maxChars) md = md.slice(0, this.maxChars) + '\n…(contexto truncado)';
|
|
308
|
+
return md;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Texto legible de un elemento (tool read_element). */
|
|
312
|
+
readElement(target, maxChars = 4000) {
|
|
313
|
+
const el = this.resolve(target);
|
|
314
|
+
if (!el) return null;
|
|
315
|
+
return this._text(el, maxChars);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Búsqueda por texto de elementos/secciones que no entraron en el snapshot. */
|
|
319
|
+
search(query, limit = 10) {
|
|
320
|
+
const q = normalize(query);
|
|
321
|
+
if (!q) return [];
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const el of document.querySelectorAll(`${INTERACTIVE}, h1, h2, h3, h4, label, [data-ots-section], [id]`)) {
|
|
324
|
+
if (out.length >= limit) break;
|
|
325
|
+
if (this._isIgnored(el) || !isVisible(el)) continue;
|
|
326
|
+
const d = this._describe(el);
|
|
327
|
+
const hay = normalize(`${d.label} ${d.name || ''} ${el.id} ${d.href || ''}`);
|
|
328
|
+
if (hay.includes(q)) out.push(d);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ───────────────────────────── Helpers ─────────────────────────────
|
|
334
|
+
|
|
335
|
+
_isIgnored(el) {
|
|
336
|
+
return !!el.closest?.(this.ignoreSel);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
_isPrivate(el) {
|
|
340
|
+
return !!el.matches?.(this.privateSel) || !!el.closest?.('[data-ots-private]');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_describe(el) {
|
|
344
|
+
const tag = el.tagName.toLowerCase();
|
|
345
|
+
const role = el.getAttribute('role');
|
|
346
|
+
const type = el.getAttribute('type');
|
|
347
|
+
const d = {
|
|
348
|
+
ref: this.refFor(el),
|
|
349
|
+
kind: role || (tag === 'input' ? `input:${type || 'text'}` : tag),
|
|
350
|
+
label: accessibleName(el).slice(0, 80),
|
|
351
|
+
};
|
|
352
|
+
if (el.id) d.id = el.id;
|
|
353
|
+
if (el.name) d.name = el.name;
|
|
354
|
+
if (tag === 'a') d.href = relHref(el.getAttribute('href'));
|
|
355
|
+
if (el.required || el.getAttribute('aria-required') === 'true') d.required = true;
|
|
356
|
+
if (el.disabled || el.getAttribute('aria-disabled') === 'true') d.disabled = true;
|
|
357
|
+
if (el.getAttribute('aria-invalid') === 'true') d.invalid = true;
|
|
358
|
+
if (type === 'checkbox' || type === 'radio') d.checked = el.checked;
|
|
359
|
+
if (tag === 'select') {
|
|
360
|
+
d.options = [...el.options].slice(0, 12).map((o) => o.text.trim());
|
|
361
|
+
d.value = this._isPrivate(el) ? '[privado]' : el.selectedOptions[0]?.text;
|
|
362
|
+
} else if ((tag === 'input' || tag === 'textarea') && !['checkbox', 'radio', 'submit', 'button'].includes(type)) {
|
|
363
|
+
if (this._isPrivate(el)) d.value = el.value ? '[privado]' : '';
|
|
364
|
+
else if (el.value) d.value = el.value.slice(0, 60);
|
|
365
|
+
if (el.placeholder) d.placeholder = el.placeholder.slice(0, 60);
|
|
366
|
+
}
|
|
367
|
+
return d;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_text(el, maxChars) {
|
|
371
|
+
// Clonamos para poder quitar zonas ignoradas/privadas sin tocar la página real.
|
|
372
|
+
const clone = el.cloneNode(true);
|
|
373
|
+
clone.querySelectorAll(this.ignoreSel).forEach((n) => n.remove());
|
|
374
|
+
clone.querySelectorAll('[data-ots-private]').forEach((n) => { n.textContent = '[privado]'; });
|
|
375
|
+
// innerText necesita layout; en un nodo desconectado cae a textContent.
|
|
376
|
+
const text = cleanText(clone.innerText || clone.textContent || '');
|
|
377
|
+
return text.length > maxChars ? text.slice(0, maxChars) + '…' : text;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_selection() {
|
|
381
|
+
const sel = window.getSelection?.();
|
|
382
|
+
if (!sel || sel.isCollapsed) return null;
|
|
383
|
+
const node = sel.anchorNode?.nodeType === 1 ? sel.anchorNode : sel.anchorNode?.parentElement;
|
|
384
|
+
if (!node || this._isIgnored(node) || this._isPrivate(node)) return null;
|
|
385
|
+
return cleanText(sel.toString()).slice(0, 500) || null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ───────────────────────────── utilidades puras ─────────────────────────────
|
|
390
|
+
|
|
391
|
+
/** Emite 'ots:locationchange' en cada pushState/replaceState (routers SPA). */
|
|
392
|
+
function patchHistoryOnce() {
|
|
393
|
+
if (history.__otsPatched) return;
|
|
394
|
+
history.__otsPatched = true;
|
|
395
|
+
for (const m of ['pushState', 'replaceState']) {
|
|
396
|
+
const orig = history[m];
|
|
397
|
+
history[m] = function (...args) {
|
|
398
|
+
const r = orig.apply(this, args);
|
|
399
|
+
window.dispatchEvent(new Event('ots:locationchange'));
|
|
400
|
+
return r;
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function isVisible(el) {
|
|
406
|
+
if (el.hidden || el.closest('[hidden], [aria-hidden=true], [inert]')) return false;
|
|
407
|
+
const r = el.getBoundingClientRect();
|
|
408
|
+
if (r.width === 0 && r.height === 0) return false;
|
|
409
|
+
const cs = getComputedStyle(el);
|
|
410
|
+
return cs.visibility !== 'hidden' && cs.display !== 'none' && Number(cs.opacity) > 0.02;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function accessibleName(el) {
|
|
414
|
+
const aria = el.getAttribute('aria-label');
|
|
415
|
+
if (aria) return cleanText(aria);
|
|
416
|
+
const lb = el.getAttribute('aria-labelledby');
|
|
417
|
+
if (lb) {
|
|
418
|
+
const t = lb.split(/\s+/).map((id) => document.getElementById(id)?.innerText || '').join(' ');
|
|
419
|
+
if (t.trim()) return cleanText(t);
|
|
420
|
+
}
|
|
421
|
+
if (el.labels?.length) return cleanText([...el.labels].map((l) => l.innerText).join(' '));
|
|
422
|
+
const own = cleanText(el.innerText || '');
|
|
423
|
+
if (own) return own;
|
|
424
|
+
return cleanText(el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt') ||
|
|
425
|
+
el.querySelector?.('img[alt]')?.alt || el.value || el.name || '');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function sectionTitle(el) {
|
|
429
|
+
return cleanText(
|
|
430
|
+
el.getAttribute('data-ots-section') || el.getAttribute('aria-label') ||
|
|
431
|
+
el.querySelector('h1, h2, h3, h4, legend')?.innerText || el.id || '',
|
|
432
|
+
).slice(0, 80) || null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function relHref(href) {
|
|
436
|
+
if (!href) return null;
|
|
437
|
+
try {
|
|
438
|
+
const u = new URL(href, location.href);
|
|
439
|
+
return u.origin === location.origin ? u.pathname + u.search + u.hash : u.href;
|
|
440
|
+
} catch {
|
|
441
|
+
return href;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function fmtEl(e) {
|
|
446
|
+
let s = `${e.kind} "${e.label || e.name || e.placeholder || ''}"`;
|
|
447
|
+
if (e.href) s += ` → ${e.href}`;
|
|
448
|
+
if (e.value !== undefined && e.value !== '') s += ` = "${e.value}"`;
|
|
449
|
+
if (e.checked !== undefined) s += e.checked ? ' [✓]' : ' [ ]';
|
|
450
|
+
if (e.options) s += ` opciones: ${e.options.join(' | ')}`;
|
|
451
|
+
if (e.required) s += ' (requerido)';
|
|
452
|
+
if (e.disabled) s += ' (deshabilitado)';
|
|
453
|
+
if (e.invalid) s += ' (inválido)';
|
|
454
|
+
return s;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function cleanText(t) {
|
|
458
|
+
return String(t).replace(/[ \t ]+/g, ' ').replace(/\n\s*\n+/g, '\n').trim();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function normalize(t) {
|
|
462
|
+
return String(t || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function safeCall(fn) {
|
|
466
|
+
if (typeof fn !== 'function') return null;
|
|
467
|
+
try {
|
|
468
|
+
return fn() ?? null;
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|