lampson 0.2.7 → 0.2.9
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 +10 -1
- package/README.md +9 -3
- package/bin/lampson.js +1 -1
- package/chat.syn +112 -8
- package/cli.syn +143 -7
- package/edge.syn +26 -0
- package/hub.tpl.syn +127 -6
- package/lampson.ps1 +43 -2
- package/lampson.sh +40 -2
- package/lib/agents.syn +30 -9
- package/lib/approvals.syn +2 -2
- package/lib/auth.syn +274 -0
- package/lib/computer.syn +2000 -0
- package/lib/computer_judge.syn +410 -0
- package/lib/fs.syn +242 -0
- package/lib/git.syn +2 -2
- package/lib/judge.syn +78 -0
- package/lib/line.syn +2 -2
- package/lib/loop.syn +41 -8
- package/lib/lsp.syn +4 -4
- package/lib/mcp.syn +20 -4
- package/lib/md.syn +11 -11
- package/lib/origin.syn +67 -0
- package/lib/permission.syn +47 -2
- package/lib/prompt.syn +10 -1
- package/lib/provider.syn +70 -20
- package/lib/schedule.syn +12 -12
- package/lib/session.syn +5 -5
- package/lib/settings.syn +65 -1
- package/lib/skills.syn +22 -6
- package/lib/tools/bash.syn +1 -1
- package/lib/tools/common.syn +1 -1
- package/lib/tools/fetch.syn +600 -0
- package/lib/tools/grep.syn +1 -1
- package/lib/tools/memo.syn +3 -3
- package/lib/tools/proc.syn +38 -20
- package/lib/tools/qr.js +26 -0
- package/lib/tools/url.syn +220 -0
- package/lib/tools.syn +25 -2
- package/lib/trace.syn +2 -2
- package/lib/tree.syn +29 -13
- package/lib/update.syn +1 -1
- package/lib/workspaces.syn +202 -36
- package/package.json +12 -3
- package/public/apple-touch-icon.png +0 -0
- package/public/badge-96.png +0 -0
- package/public/css/code.css +48 -0
- package/public/css/hub.css +9 -0
- package/public/css/login.css +16 -0
- package/public/css/panel.css +30 -0
- package/public/css/sidebar.css +21 -0
- package/public/css/tokens.css +1 -1
- package/public/hub.html +11 -1
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/icons-src/badge.svg +12 -0
- package/public/icons-src/icon-maskable.svg +15 -0
- package/public/icons-src/icon.svg +13 -0
- package/public/index.html +14 -3
- package/public/js/agents.js +1 -1
- package/public/js/chat.js +1 -1
- package/public/js/config.js +145 -2
- package/public/js/core.js +5 -3
- package/public/js/devices.js +80 -0
- package/public/js/editor.js +232 -0
- package/public/js/login.js +78 -0
- package/public/js/procs.js +1 -1
- package/public/js/pwa.js +5 -0
- package/public/js/tree.js +224 -14
- package/public/login.html +43 -0
- package/public/manifest.webmanifest +16 -0
- package/public/offline.html +24 -0
- package/public/sw.js +63 -0
- package/public/vendor/highlight.min.js +1343 -0
- package/public/vendor/qrcode.js +2297 -0
- package/skills/computer-use/SKILL.md +178 -0
- package/skills/lampson/SKILL.md +13 -0
- package/web.syn +82 -7
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
-- lib/computer_judge.syn — «jev-use» para computer_use: un modelo System One (Jev de TypeSafe, o Laya local) elige
|
|
2
|
+
-- la próxima acción de una lista CERRADA de candidatos, en vez de que el LLM lea el árbol entero y elija un índice
|
|
3
|
+
--
|
|
4
|
+
-- De dónde sale (2026-09-28): la receta jev-use de trycua/cua (libs/cua-driver/examples/jev-use, RFC 4268). Lo que
|
|
5
|
+
-- se copió porque allá está medido:
|
|
6
|
+
-- * el modelo nunca inventa: elige UN id de una tabla que arma el harness; el id se resuelve a la acción ya
|
|
7
|
+
-- armada (elemento + snapshot, o ref de página). El texto a tipear sale del LLM (`text`), nunca del juez.
|
|
8
|
+
-- * MEDIDO ACÁ (2026-09-28, Calculadora de Windows en español, Laya local = checkpoint `english`): el backend
|
|
9
|
+
-- local elige por el ID de la opción, no por su descripción ("press 7" con ids b0…b29 → b7; idéntico con
|
|
10
|
+
-- etiquetas en inglés o en español). Por eso el id ES legible: «button Siete». Y con la interfaz en español el
|
|
11
|
+
-- checkpoint inglés se equivoca con confianza 100% («presionar 7» → «Tres»): la compuerta no protege ahí; el
|
|
12
|
+
-- objetivo tiene que venir con las palabras de la pantalla y el LLM verifica siempre.
|
|
13
|
+
-- * candidatos: solo controles con clase conocida (button, checkbox, radio, popup, menu_item, link, tab,
|
|
14
|
+
-- text_input, toggle, option), habilitados, con tamaño, con etiqueta (y etiqueta ≠ value: así el contenido
|
|
15
|
+
-- tipeado nunca termina en un id), fuera de la barra de título. Ids ESTABLES (clase + etiqueta), nunca índices.
|
|
16
|
+
-- Tope 32 + los reservados `reobserve` y `abstain` (Jev eligió bien 220/220 con 3-7 candidatos; con más de
|
|
17
|
+
-- 12 no está medido — por eso, pasado el tope, se ordena por parecido con el objetivo antes de cortar). El
|
|
18
|
+
-- 24 de cua viene del límite de 26 letras de Cua-S1; una Calculadora ya trae 29 controles útiles.
|
|
19
|
+
-- * riesgo por palabra entera (borrar, enviar, comprar, cerrar): esos controles NO se ofrecen — el LLM los hace
|
|
20
|
+
-- a mano con element=N / ref, y pasan por la aprobación normal. Es una guarda contra accidentes, no autorización.
|
|
21
|
+
-- * el modelo NO decide si la tarea terminó (Jev contesta «listo» en el paso 0): eso lo verifica el LLM.
|
|
22
|
+
-- * cua no pone umbral (lo deja al que llama): acá hay compuerta de probabilidad Y de margen sobre el segundo.
|
|
23
|
+
--
|
|
24
|
+
-- Puro: sin driver ni juez; lo usa lib/computer.syn (acción `act`, y page_select cuando el texto no coincide).
|
|
25
|
+
|
|
26
|
+
-- clase → acción. Roles de Windows UIA, macOS AX (sin el prefijo AX), Linux AT-SPI y ARIA (página), normalizados
|
|
27
|
+
-- (minúsculas, solo letras y dígitos). UIA "Text" es texto estático (AT-SPI "text" es editable, pero en Linux la
|
|
28
|
+
-- ruta nativa todavía no se probó): no entra.
|
|
29
|
+
export let ROLE_CLASS be {
|
|
30
|
+
"button": "button", "splitbutton": "button", "pushbutton": "button", "menubutton": "popup",
|
|
31
|
+
"checkbox": "checkbox", "menuitemcheckbox": "checkbox",
|
|
32
|
+
"radiobutton": "radio", "radio": "radio", "menuitemradio": "radio",
|
|
33
|
+
"switch": "toggle", "togglebutton": "toggle",
|
|
34
|
+
"combobox": "popup", "popupbutton": "popup", "listbox": "popup",
|
|
35
|
+
"menuitem": "menu_item", "menubaritem": "menu_item",
|
|
36
|
+
"hyperlink": "link", "link": "link",
|
|
37
|
+
"tabitem": "tab", "tab": "tab", "pagetab": "tab",
|
|
38
|
+
"edit": "text_input", "textbox": "text_input", "searchbox": "text_input", "textfield": "text_input", "textarea": "text_input", "searchfield": "text_input", "entry": "text_input", "spinbutton": "text_input",
|
|
39
|
+
"option": "option", "listitem": "option", "treeitem": "option"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export let CLASS_ACTION be {"button": "press", "checkbox": "toggle", "radio": "select", "toggle": "toggle", "popup": "open_menu", "menu_item": "press", "link": "press", "tab": "press", "text_input": "set_text", "option": "select"}
|
|
43
|
+
|
|
44
|
+
-- palabra entera, sin distinguir mayúsculas; en inglés y en español (las etiquetas vienen en el idioma del usuario)
|
|
45
|
+
export let RISK_WORDS be {
|
|
46
|
+
"destructive": ["delete", "remove", "erase", "trash", "discard", "clear all", "format", "reset", "eliminar", "borrar", "quitar", "vaciar", "descartar", "restablecer", "formatear", "papelera"],
|
|
47
|
+
"send": ["send", "submit", "post", "publish", "share", "reply", "enviar", "publicar", "compartir", "responder"],
|
|
48
|
+
"purchase": ["buy", "purchase", "pay", "checkout", "order", "subscribe", "comprar", "pagar", "suscribirse", "suscribir", "pedido", "finalizar compra"],
|
|
49
|
+
"close_unsaved": ["close", "quit", "exit", "don't save", "discard changes", "cerrar", "salir", "no guardar", "descartar cambios"]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export let MAX_CANDIDATES be 32
|
|
53
|
+
export let MAX_HISTORY be 16
|
|
54
|
+
export let MAX_ELEMENTS_IN_STATE be 64
|
|
55
|
+
-- compuerta: probabilidad del elegido y margen sobre el segundo (cua documenta que el llamador debe ponerlas)
|
|
56
|
+
export let MIN_P be 0.6
|
|
57
|
+
export let MIN_MARGIN be 0.15
|
|
58
|
+
export let MAX_STEPS be 6
|
|
59
|
+
|
|
60
|
+
export let RESERVED be {
|
|
61
|
+
"reobserve": "Take no action and obtain a fresh observation of the window, because the current observation looks stale, incomplete, or contradicts the goal's progress.",
|
|
62
|
+
"abstain": "Stop without acting if none of the proposed actions is safe or moves toward the goal."
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export let INSTRUCTION be "Which single action should run next to move toward the goal?"
|
|
66
|
+
|
|
67
|
+
-- "AXButton" | "Button" | "push button" → "button"
|
|
68
|
+
export task norm_role(r)
|
|
69
|
+
let low be regex_replace(lower(text(r)), "[^a-z0-9]", "")
|
|
70
|
+
when starts_with(low, "ax") and length(low) > 2
|
|
71
|
+
set low to slice(low, 2, length(low))
|
|
72
|
+
give low
|
|
73
|
+
|
|
74
|
+
export task role_class(r)
|
|
75
|
+
let n be norm_role(r)
|
|
76
|
+
when contains(ROLE_CLASS, n)
|
|
77
|
+
give ROLE_CLASS[n]
|
|
78
|
+
give nothing
|
|
79
|
+
|
|
80
|
+
task squash(s)
|
|
81
|
+
give trim(regex_replace(replace(text(s), "’", "'"), "\\s+", " "))
|
|
82
|
+
|
|
83
|
+
-- categoría de riesgo de una etiqueta, o nothing (palabra entera: «Resetear» no es «reset», «Guardar» no es «guardar como»)
|
|
84
|
+
export task risk_of(label)
|
|
85
|
+
let low be " " + regex_replace(lower(squash(label)), "[^a-z0-9áéíóúñü' ]", " ") + " "
|
|
86
|
+
each cat in keys(RISK_WORDS)
|
|
87
|
+
each w in RISK_WORDS[cat]
|
|
88
|
+
when contains(low, " " + w + " ")
|
|
89
|
+
give cat
|
|
90
|
+
give nothing
|
|
91
|
+
|
|
92
|
+
-- id legible de un candidato: «button Guardar», «text_input Nombre de archivo» (el modelo lee el id: ver arriba)
|
|
93
|
+
export task readable_id(cls, label)
|
|
94
|
+
let l be squash(label)
|
|
95
|
+
when length(l) > 48
|
|
96
|
+
set l to trim(slice(l, 0, 48))
|
|
97
|
+
give cls + " " + l
|
|
98
|
+
|
|
99
|
+
-- slug ASCII de 32 como máximo; sin nada ASCII → 8 hex del sha256
|
|
100
|
+
let TRANSLIT be {"á": "a", "à": "a", "ä": "a", "â": "a", "ã": "a", "é": "e", "è": "e", "ë": "e", "ê": "e", "í": "i", "ì": "i", "ï": "i", "î": "i", "ó": "o", "ò": "o", "ö": "o", "ô": "o", "õ": "o", "ú": "u", "ù": "u", "ü": "u", "û": "u", "ñ": "n", "ç": "c"}
|
|
101
|
+
|
|
102
|
+
task ascii_fold(s)
|
|
103
|
+
let out be lower(text(s))
|
|
104
|
+
each k in keys(TRANSLIT)
|
|
105
|
+
set out to replace(out, k, TRANSLIT[k])
|
|
106
|
+
give out
|
|
107
|
+
|
|
108
|
+
export task slug(label)
|
|
109
|
+
let s be regex_replace(ascii_fold(squash(label)), "[^a-z0-9]+", "-")
|
|
110
|
+
set s to regex_replace(s, "^-+|-+$", "")
|
|
111
|
+
when length(s) > 32
|
|
112
|
+
set s to regex_replace(slice(s, 0, 32), "-+$", "")
|
|
113
|
+
when s == ""
|
|
114
|
+
give slice(hex(sha256(text(label))), 2, 10)
|
|
115
|
+
give s
|
|
116
|
+
|
|
117
|
+
task short(s, n)
|
|
118
|
+
let t be squash(s)
|
|
119
|
+
give when length(t) > n then slice(t, 0, n) + "…" otherwise t
|
|
120
|
+
|
|
121
|
+
-- descripción que lee el modelo (las de cua, literales donde se pudo)
|
|
122
|
+
export task describe_candidate(cls, label, state, text_value)
|
|
123
|
+
-- comillas a mano: json_encode escapa lo no ASCII (ú) y el modelo leería «Menú»
|
|
124
|
+
let q be "\"" + replace(short(label, 60), "\"", "'") + "\""
|
|
125
|
+
when cls == "text_input"
|
|
126
|
+
let st be when state == "" then "" otherwise " The field currently reports " + state + "."
|
|
127
|
+
give "Set the text field " + q + " to the text the task supplies" + (when text_value != nothing then " (\"" + replace(short(text_value, 40), "\"", "'") + "\")" otherwise "") + ", replacing its contents." + st
|
|
128
|
+
when cls == "checkbox" or cls == "toggle"
|
|
129
|
+
give "Toggle the " + (when cls == "checkbox" then "checkbox " otherwise "switch ") + q + "." + (when state != "" then " It is currently " + state + "." otherwise "")
|
|
130
|
+
when cls == "radio"
|
|
131
|
+
give "Select the radio option " + q + "." + (when state != "" then " It is currently " + state + "." otherwise "")
|
|
132
|
+
when cls == "popup"
|
|
133
|
+
give "Open the pop-up menu " + q + ". Its options become candidates on the next observation."
|
|
134
|
+
when cls == "option"
|
|
135
|
+
give "Select the option " + q + "." + (when state != "" then " It is currently " + state + "." otherwise "")
|
|
136
|
+
when cls == "menu_item"
|
|
137
|
+
give "Press the menu item labeled " + q + "."
|
|
138
|
+
when cls == "tab"
|
|
139
|
+
give "Switch to the tab labeled " + q + "."
|
|
140
|
+
when cls == "link"
|
|
141
|
+
give "Follow the link labeled " + q + "."
|
|
142
|
+
give "Press the button labeled " + q + "."
|
|
143
|
+
|
|
144
|
+
task num_or(m, k, d)
|
|
145
|
+
when not contains(m, k)
|
|
146
|
+
give d
|
|
147
|
+
when m[k] == nothing
|
|
148
|
+
give d
|
|
149
|
+
try
|
|
150
|
+
give number(text(m[k]))
|
|
151
|
+
recover err
|
|
152
|
+
give d
|
|
153
|
+
|
|
154
|
+
task txt(m, k)
|
|
155
|
+
when not contains(m, k)
|
|
156
|
+
give ""
|
|
157
|
+
when m[k] == nothing
|
|
158
|
+
give ""
|
|
159
|
+
give text(m[k])
|
|
160
|
+
|
|
161
|
+
-- estado sin values (nunca viaja el contenido de un campo): empty / has_text / contains_required_value …
|
|
162
|
+
task native_state(e, cls, want_text)
|
|
163
|
+
when cls == "text_input"
|
|
164
|
+
let v be txt(e, "value")
|
|
165
|
+
when trim(v) == ""
|
|
166
|
+
give "empty"
|
|
167
|
+
when want_text != nothing and trim(v) == trim(text(want_text))
|
|
168
|
+
give "contains_required_value"
|
|
169
|
+
give "has_text"
|
|
170
|
+
when cls == "checkbox" or cls == "toggle"
|
|
171
|
+
when contains(e, "selected")
|
|
172
|
+
give when e["selected"] == true then "checked" otherwise "unchecked"
|
|
173
|
+
let v be lower(txt(e, "value"))
|
|
174
|
+
when v == "on" or v == "true" or v == "1" or v == "checked"
|
|
175
|
+
give "checked"
|
|
176
|
+
when v == "off" or v == "false" or v == "0" or v == "unchecked"
|
|
177
|
+
give "unchecked"
|
|
178
|
+
give ""
|
|
179
|
+
when cls == "radio" or cls == "option"
|
|
180
|
+
when contains(e, "selected")
|
|
181
|
+
give when e["selected"] == true then "selected" otherwise "not selected"
|
|
182
|
+
give ""
|
|
183
|
+
give ""
|
|
184
|
+
|
|
185
|
+
-- ¿cuelga de la barra de título? (Minimizar/Maximizar/Cerrar/menú de sistema no son candidatos)
|
|
186
|
+
task in_title_bar(e, by_index)
|
|
187
|
+
let cur be e
|
|
188
|
+
let hops be 0
|
|
189
|
+
while cur != nothing and hops < 40
|
|
190
|
+
when norm_role(txt(cur, "role")) == "titlebar"
|
|
191
|
+
give true
|
|
192
|
+
let pi be num_or(cur, "parent_index", -1)
|
|
193
|
+
set cur to when pi >= 0 and contains(by_index, text(floor(pi))) then by_index[text(floor(pi))] otherwise nothing
|
|
194
|
+
set hops to hops + 1
|
|
195
|
+
give false
|
|
196
|
+
|
|
197
|
+
-- botones de ventana que no cuelgan de un TitleBar (apps UWP en ApplicationFrameHost: «Minimizar Calculadora»)
|
|
198
|
+
let CHROME_WORDS be ["minimize", "maximize", "restore", "minimizar", "maximizar", "restaurar"]
|
|
199
|
+
|
|
200
|
+
task is_chrome_label(label)
|
|
201
|
+
let first be lower(split(squash(label) + " ", " ")[0])
|
|
202
|
+
give contains(CHROME_WORDS, first)
|
|
203
|
+
|
|
204
|
+
-- palabras del objetivo (3+ letras) que aparecen en la etiqueta: para ordenar cuando hay más candidatos que el tope
|
|
205
|
+
task goal_words(goal)
|
|
206
|
+
let out be []
|
|
207
|
+
each w in split(regex_replace(lower(text(goal)), "[^a-z0-9áéíóúñü]+", " "), " ")
|
|
208
|
+
when length(w) >= 3 and not contains(out, w)
|
|
209
|
+
set out to append(out, w)
|
|
210
|
+
give out
|
|
211
|
+
|
|
212
|
+
task overlap(words, label)
|
|
213
|
+
let low be lower(text(label))
|
|
214
|
+
let n be 0
|
|
215
|
+
each w in words
|
|
216
|
+
when contains(low, w)
|
|
217
|
+
set n to n + 1
|
|
218
|
+
give n
|
|
219
|
+
|
|
220
|
+
-- palabra clave del objetivo para filtrar un snapshot (query): la más larga que no sea un verbo de acción ni un
|
|
221
|
+
-- sustantivo genérico de interfaz. «aplicar el filtro Con control de temperatura» → «temperatura»; «completar el
|
|
222
|
+
-- correo» → «correo». nothing si no queda ninguna.
|
|
223
|
+
let QUERY_STOP be ["completar", "presionar", "escribir", "buscar", "aplicar", "seleccionar", "elegir", "marcar", "abrir", "cerrar", "hacer", "click", "clic", "clickear", "responder", "poner", "ingresar", "llenar", "rellenar", "tocar", "ir", "press", "type", "fill", "open", "select", "choose", "enter", "search", "apply", "tick", "check", "click", "write", "set", "the", "field", "campo", "botón", "boton", "button", "filtro", "filter", "opción", "opcion", "option", "link", "enlace", "menu", "menú", "para", "con", "del", "los", "las", "una", "uno", "que", "sobre", "with", "into", "from", "that", "this"]
|
|
224
|
+
|
|
225
|
+
export task goal_query(goal)
|
|
226
|
+
let best be nothing
|
|
227
|
+
each w in split(regex_replace(lower(text(goal)), "[^a-z0-9áéíóúñü@._-]+", " "), " ")
|
|
228
|
+
when length(w) >= 4 and not contains(QUERY_STOP, w)
|
|
229
|
+
when best == nothing or length(w) > length(best)
|
|
230
|
+
set best to w
|
|
231
|
+
give best
|
|
232
|
+
|
|
233
|
+
-- ids únicos: el segundo con la misma base lleva « (2)» (estable entre observaciones iguales)
|
|
234
|
+
task unique_id(base, seen)
|
|
235
|
+
when not contains(seen, base)
|
|
236
|
+
give base
|
|
237
|
+
let k be 2
|
|
238
|
+
while contains(seen, base + " (" + text(k) + ")")
|
|
239
|
+
set k to k + 1
|
|
240
|
+
give base + " (" + text(k) + ")"
|
|
241
|
+
|
|
242
|
+
-- recorte al tope: si sobran, primero los que comparten palabras con el objetivo (orden estable dentro de cada grupo)
|
|
243
|
+
task cap_ranked(cands, goal)
|
|
244
|
+
when length(cands) <= MAX_CANDIDATES
|
|
245
|
+
give {"cands": cands, "dropped": 0}
|
|
246
|
+
let words be goal_words(goal)
|
|
247
|
+
let scored be []
|
|
248
|
+
let i be 0
|
|
249
|
+
each c in cands
|
|
250
|
+
set scored to append(scored, {"c": c, "s": overlap(words, c["label"]), "i": i})
|
|
251
|
+
set i to i + 1
|
|
252
|
+
let ranked be sort_by(scored, (x) => (0 - x["s"]) * 100000 + x["i"])
|
|
253
|
+
let out be []
|
|
254
|
+
each x in slice(ranked, 0, MAX_CANDIDATES)
|
|
255
|
+
set out to append(out, x["c"])
|
|
256
|
+
-- de vuelta al orden de pantalla: el modelo lee la lista como está en la ventana
|
|
257
|
+
give {"cands": sort_by(out, (c) => c["order"]), "dropped": length(cands) - MAX_CANDIDATES}
|
|
258
|
+
|
|
259
|
+
-- elementos de get_window_state → {cands: [{id, desc, cls, action, label, state, element}], excluded: {motivo: n},
|
|
260
|
+
-- risky: [etiquetas], dropped, elements: [{role_class, label, state}]}. `want_text` = el texto que el LLM pasó
|
|
261
|
+
-- (sin él no hay candidatos set_text).
|
|
262
|
+
export task native_candidates(elements, goal, want_text)
|
|
263
|
+
let by_index be {}
|
|
264
|
+
each e in elements
|
|
265
|
+
when contains(e, "element_index")
|
|
266
|
+
set by_index[text(floor(number(text(e["element_index"]))))] to e
|
|
267
|
+
let cands be []
|
|
268
|
+
let seen be []
|
|
269
|
+
let excluded be {}
|
|
270
|
+
let risky be []
|
|
271
|
+
let summary be []
|
|
272
|
+
let order be 0
|
|
273
|
+
each e in elements
|
|
274
|
+
let cls be role_class(txt(e, "role"))
|
|
275
|
+
let why be nothing
|
|
276
|
+
let label be squash(txt(e, "label"))
|
|
277
|
+
when cls == nothing
|
|
278
|
+
set why to "unknown_role"
|
|
279
|
+
otherwise when contains(e, "enabled") and e["enabled"] == false
|
|
280
|
+
set why to "disabled"
|
|
281
|
+
otherwise when not contains(e, "element_index")
|
|
282
|
+
set why to "no_index"
|
|
283
|
+
otherwise when label == ""
|
|
284
|
+
set why to "unlabelled"
|
|
285
|
+
otherwise when cls != "text_input" and label == squash(txt(e, "value"))
|
|
286
|
+
set why to "label_is_value"
|
|
287
|
+
otherwise when cls == "text_input" and trim(txt(e, "value")) != "" and label == squash(txt(e, "value"))
|
|
288
|
+
set why to "label_is_value"
|
|
289
|
+
otherwise when contains(e, "frame") and e["frame"] != nothing and (num_or(e["frame"], "w", 1) <= 0 or num_or(e["frame"], "h", 1) <= 0)
|
|
290
|
+
set why to "off_screen"
|
|
291
|
+
otherwise when in_title_bar(e, by_index) or is_chrome_label(label)
|
|
292
|
+
set why to "window_chrome"
|
|
293
|
+
otherwise when cls == "text_input" and want_text == nothing
|
|
294
|
+
set why to "no_text_supplied"
|
|
295
|
+
when why == nothing
|
|
296
|
+
let rk be risk_of(label)
|
|
297
|
+
when rk != nothing
|
|
298
|
+
set why to "risk_" + rk
|
|
299
|
+
set risky to append(risky, label)
|
|
300
|
+
when why != nothing
|
|
301
|
+
set excluded[why] to (when contains(excluded, why) then excluded[why] otherwise 0) + 1
|
|
302
|
+
otherwise
|
|
303
|
+
let state be native_state(e, cls, want_text)
|
|
304
|
+
let id be unique_id(readable_id(cls, label), seen)
|
|
305
|
+
set seen to append(seen, id)
|
|
306
|
+
set cands to append(cands, {"id": id, "desc": describe_candidate(cls, label, state, want_text), "cls": cls, "action": CLASS_ACTION[cls], "label": label, "state": state, "element": floor(number(text(e["element_index"]))), "order": order})
|
|
307
|
+
set order to order + 1
|
|
308
|
+
when length(summary) < MAX_ELEMENTS_IN_STATE
|
|
309
|
+
set summary to append(summary, {"role_class": cls, "label": short(label, 60), "state": state})
|
|
310
|
+
let capped be cap_ranked(cands, goal)
|
|
311
|
+
give {"cands": capped["cands"], "excluded": excluded, "risky": risky, "dropped": capped["dropped"], "elements": summary}
|
|
312
|
+
|
|
313
|
+
-- refs de un snapshot de página (semantic_v2: {ref, role, name, actions, visibility}) → la misma forma, con `ref`
|
|
314
|
+
export task page_candidates(refs, goal, want_text)
|
|
315
|
+
let cands be []
|
|
316
|
+
let seen be []
|
|
317
|
+
let excluded be {}
|
|
318
|
+
let risky be []
|
|
319
|
+
let summary be []
|
|
320
|
+
let order be 0
|
|
321
|
+
each r in refs
|
|
322
|
+
let cls be role_class(txt(r, "role"))
|
|
323
|
+
-- en la web un combobox suele ser un campo con autocompletado (el buscador de MercadoLibre): con texto para
|
|
324
|
+
-- escribir va como campo; describirlo como «abrir menú» bajó a Jev al 59% (2026-09-28)
|
|
325
|
+
when cls == "popup" and want_text != nothing and norm_role(txt(r, "role")) == "combobox"
|
|
326
|
+
set cls to "text_input"
|
|
327
|
+
let label be squash(txt(r, "name"))
|
|
328
|
+
let why be nothing
|
|
329
|
+
when cls == nothing
|
|
330
|
+
set why to "unknown_role"
|
|
331
|
+
otherwise when label == ""
|
|
332
|
+
set why to "unlabelled"
|
|
333
|
+
otherwise when txt(r, "ref") == ""
|
|
334
|
+
set why to "no_ref"
|
|
335
|
+
otherwise when cls == "text_input" and want_text == nothing
|
|
336
|
+
set why to "no_text_supplied"
|
|
337
|
+
otherwise when lower(txt(r, "visibility")) == "hidden"
|
|
338
|
+
set why to "hidden"
|
|
339
|
+
when why == nothing
|
|
340
|
+
let rk be risk_of(label)
|
|
341
|
+
when rk != nothing
|
|
342
|
+
set why to "risk_" + rk
|
|
343
|
+
set risky to append(risky, label)
|
|
344
|
+
when why != nothing
|
|
345
|
+
set excluded[why] to (when contains(excluded, why) then excluded[why] otherwise 0) + 1
|
|
346
|
+
otherwise
|
|
347
|
+
let id be unique_id(readable_id(cls, label), seen)
|
|
348
|
+
set seen to append(seen, id)
|
|
349
|
+
set cands to append(cands, {"id": id, "desc": describe_candidate(cls, label, "", want_text), "cls": cls, "action": CLASS_ACTION[cls], "label": label, "state": "", "ref": txt(r, "ref"), "order": order})
|
|
350
|
+
set order to order + 1
|
|
351
|
+
when length(summary) < MAX_ELEMENTS_IN_STATE
|
|
352
|
+
set summary to append(summary, {"role_class": cls, "label": short(label, 60), "state": ""})
|
|
353
|
+
let capped be cap_ranked(cands, goal)
|
|
354
|
+
give {"cands": capped["cands"], "excluded": excluded, "risky": risky, "dropped": capped["dropped"], "elements": summary}
|
|
355
|
+
|
|
356
|
+
-- criteria del `choose`: {id: descripción} con los reservados al final
|
|
357
|
+
export task criteria(cands)
|
|
358
|
+
let m be {}
|
|
359
|
+
each c in cands
|
|
360
|
+
set m[c["id"]] to c["desc"]
|
|
361
|
+
each k in keys(RESERVED)
|
|
362
|
+
set m[k] to RESERVED[k]
|
|
363
|
+
give m
|
|
364
|
+
|
|
365
|
+
-- estado que ve el juez: objetivo, dónde, historial acotado. Sin la lista de elementos: las opciones YA son los
|
|
366
|
+
-- controles, y Laya tiene 512 tokens de contexto (estado + pregunta + opciones) — lo que sobra se trunca por el final
|
|
367
|
+
export task decision_state(goal, where, elements, history)
|
|
368
|
+
let h be history
|
|
369
|
+
when length(h) > MAX_HISTORY
|
|
370
|
+
set h to slice(h, length(h) - MAX_HISTORY, length(h))
|
|
371
|
+
let st be {"goal": short(goal, 600), "where": where}
|
|
372
|
+
when length(h) > 0
|
|
373
|
+
set st["history"] to h
|
|
374
|
+
give st
|
|
375
|
+
|
|
376
|
+
-- compuerta sobre una respuesta `choose` del bloque judge →
|
|
377
|
+
-- {verdict: "act" | "reobserve" | "abstain" | "unsure" | "offline", id, p, margin, top: [{id, p}]}
|
|
378
|
+
export task gate(answer, min_p, min_margin)
|
|
379
|
+
when answer == nothing
|
|
380
|
+
give {"verdict": "offline", "id": nothing, "p": 0, "margin": 0, "top": []}
|
|
381
|
+
when not contains(answer, "available") or answer["available"] != true
|
|
382
|
+
give {"verdict": "offline", "id": nothing, "p": 0, "margin": 0, "top": []}
|
|
383
|
+
let probs be when contains(answer, "probabilities") then answer["probabilities"] otherwise {}
|
|
384
|
+
let pairs be []
|
|
385
|
+
each k in keys(probs)
|
|
386
|
+
set pairs to append(pairs, {"id": k, "p": number(text(probs[k]))})
|
|
387
|
+
let ranked be sort_by(pairs, (x) => 0 - x["p"])
|
|
388
|
+
let top be slice(ranked, 0, when length(ranked) < 4 then length(ranked) otherwise 4)
|
|
389
|
+
when length(ranked) == 0
|
|
390
|
+
give {"verdict": "unsure", "id": nothing, "p": 0, "margin": 0, "top": []}
|
|
391
|
+
let best be ranked[0]
|
|
392
|
+
let second be when length(ranked) > 1 then ranked[1]["p"] otherwise 0
|
|
393
|
+
let margin be best["p"] - second
|
|
394
|
+
-- `none` (la opción de escape de `or nothing`) o el reservado abstain: el juez no ve nada que sirva
|
|
395
|
+
when best["id"] == "none" or best["id"] == "abstain"
|
|
396
|
+
give {"verdict": "abstain", "id": best["id"], "p": best["p"], "margin": margin, "top": top}
|
|
397
|
+
-- empate arriba: no es accionable (cua: «a tied top score is not actionable»)
|
|
398
|
+
when margin <= 0
|
|
399
|
+
give {"verdict": "unsure", "id": best["id"], "p": best["p"], "margin": margin, "top": top}
|
|
400
|
+
when best["p"] < min_p or margin < min_margin
|
|
401
|
+
give {"verdict": "unsure", "id": best["id"], "p": best["p"], "margin": margin, "top": top}
|
|
402
|
+
when best["id"] == "reobserve"
|
|
403
|
+
give {"verdict": "reobserve", "id": best["id"], "p": best["p"], "margin": margin, "top": top}
|
|
404
|
+
give {"verdict": "act", "id": best["id"], "p": best["p"], "margin": margin, "top": top}
|
|
405
|
+
|
|
406
|
+
export task find(cands, id)
|
|
407
|
+
each c in cands
|
|
408
|
+
when c["id"] == id
|
|
409
|
+
give c
|
|
410
|
+
give nothing
|
package/lib/fs.syn
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
-- lib/fs.syn — operaciones de archivos del explorador web: crear, renombrar, mover, copiar, borrar
|
|
2
|
+
--
|
|
3
|
+
-- El runtime tiene write_file/list_dir/file_info pero NO rename/delete/copy/mkdir (v0.6.14), así que mover,
|
|
4
|
+
-- copiar y borrar corren por el shell POSIX (Git Bash en Windows), igual que plugins.remove. Todo path viene
|
|
5
|
+
-- del navegador y pasa por common.ws(): relativo al workspace, sin `..` ni absolutos (raise instructivo).
|
|
6
|
+
-- Reglas al estilo VS Code: nunca pisar un destino existente; duplicar = "<nombre> copy<.ext>", luego
|
|
7
|
+
-- "<nombre> copy 2<.ext>"…; una carpeta no se mueve/copia dentro de sí misma; la raíz no se toca.
|
|
8
|
+
use "./tools/common.syn" as c
|
|
9
|
+
|
|
10
|
+
task sh_quote(s)
|
|
11
|
+
give "'" + replace(s, "'", "'\\''") + "'"
|
|
12
|
+
|
|
13
|
+
-- shell POSIX aunque LAMPSON_SHELL apunte a cmd: mv/cp/rm/mkdir son de Git Bash
|
|
14
|
+
task shell()
|
|
15
|
+
require env("LAMPSON_*")
|
|
16
|
+
require env("OS")
|
|
17
|
+
let is_win be env("OS", "") == "Windows_NT"
|
|
18
|
+
let sh be env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
|
|
19
|
+
when is_win and not c.ends_with(lower(sh), "bash.exe")
|
|
20
|
+
set sh to "C:\\Program Files\\Git\\bin\\bash.exe"
|
|
21
|
+
give sh
|
|
22
|
+
|
|
23
|
+
-- corre un comando con cwd = workspace; error legible si falla
|
|
24
|
+
task sh(cmd)
|
|
25
|
+
require exec
|
|
26
|
+
require env("LAMPSON_*")
|
|
27
|
+
require env("OS")
|
|
28
|
+
let r be run(shell(), ["-c", cmd], 30, {"cwd": c.ROOT})
|
|
29
|
+
when r["exit_code"] != 0
|
|
30
|
+
let err be trim(text(r["stderr"]))
|
|
31
|
+
raise(when err == "" then "command failed: " + cmd otherwise err)
|
|
32
|
+
give true
|
|
33
|
+
|
|
34
|
+
-- path del navegador → {rel (como lo ve el modelo, "." = raíz), real ("workspace/…"), info}
|
|
35
|
+
task target(path)
|
|
36
|
+
require file.read("workspace")
|
|
37
|
+
require file.read("workspace/*")
|
|
38
|
+
let real be c.ws(path)
|
|
39
|
+
give {"rel": c.unws(real), "real": real, "info": file_info(real)}
|
|
40
|
+
|
|
41
|
+
task parent_of(rel)
|
|
42
|
+
when not contains(rel, "/")
|
|
43
|
+
give "."
|
|
44
|
+
let parts be split(rel, "/")
|
|
45
|
+
give join(slice(parts, 0, length(parts) - 1), "/")
|
|
46
|
+
|
|
47
|
+
task base_of(rel)
|
|
48
|
+
let parts be split(rel, "/")
|
|
49
|
+
give parts[length(parts) - 1]
|
|
50
|
+
|
|
51
|
+
task join_rel(dir, name)
|
|
52
|
+
give when dir == "." or dir == "" then name otherwise dir + "/" + name
|
|
53
|
+
|
|
54
|
+
task check_name(name)
|
|
55
|
+
let n be trim(text(name))
|
|
56
|
+
when n == "" or n == "." or n == ".." or contains(n, "/") or contains(n, "\\")
|
|
57
|
+
raise("invalid name \"" + text(name) + "\": one path segment, without / or \\")
|
|
58
|
+
when length(regex_find_all(n, "[<>:\"|?*]")) > 0
|
|
59
|
+
raise("invalid name \"" + n + "\": < > : \" | ? * are not allowed in file names")
|
|
60
|
+
give n
|
|
61
|
+
|
|
62
|
+
task must_exist(t)
|
|
63
|
+
when not t["info"]["exists"]
|
|
64
|
+
raise("\"" + t["rel"] + "\" does not exist")
|
|
65
|
+
|
|
66
|
+
task must_not_exist(rel)
|
|
67
|
+
require file.read("workspace")
|
|
68
|
+
require file.read("workspace/*")
|
|
69
|
+
when file_exists(c.ROOT + "/" + rel)
|
|
70
|
+
raise("\"" + rel + "\" already exists")
|
|
71
|
+
|
|
72
|
+
-- ¿`path` es `dir` o está dentro de `dir`?
|
|
73
|
+
task inside(dir, path)
|
|
74
|
+
when dir == "."
|
|
75
|
+
give true
|
|
76
|
+
give path == dir or starts_with(path, dir + "/")
|
|
77
|
+
|
|
78
|
+
task must_be_dir(rel, info)
|
|
79
|
+
when not info["exists"]
|
|
80
|
+
raise("\"" + rel + "\" does not exist")
|
|
81
|
+
when not info["is_dir"]
|
|
82
|
+
raise("\"" + rel + "\" is not a folder")
|
|
83
|
+
|
|
84
|
+
export task mkdir(dir, name)
|
|
85
|
+
require exec
|
|
86
|
+
require env("LAMPSON_*")
|
|
87
|
+
require env("OS")
|
|
88
|
+
require file("workspace")
|
|
89
|
+
require file("workspace/*")
|
|
90
|
+
let t be target(dir)
|
|
91
|
+
let rel be join_rel(t["rel"], check_name(name))
|
|
92
|
+
must_not_exist(rel)
|
|
93
|
+
sh("mkdir -p " + sh_quote(rel))
|
|
94
|
+
give {"path": rel, "is_dir": true}
|
|
95
|
+
|
|
96
|
+
export task create(dir, name, content)
|
|
97
|
+
require file("workspace")
|
|
98
|
+
require file("workspace/*")
|
|
99
|
+
let t be target(dir)
|
|
100
|
+
let rel be join_rel(t["rel"], check_name(name))
|
|
101
|
+
must_not_exist(rel)
|
|
102
|
+
write_file(c.ROOT + "/" + rel, when content == nothing then "" otherwise text(content))
|
|
103
|
+
give {"path": rel, "is_dir": false}
|
|
104
|
+
|
|
105
|
+
-- guardar desde el editor de la web: pisa SOLO un archivo que ya existe (crear es `create`), dentro del workspace.
|
|
106
|
+
-- "¿cambió en disco desde que lo abrí?" lo comprueba el cliente releyendo antes de guardar (js/editor.js), así el
|
|
107
|
+
-- servidor no guarda estado por archivo.
|
|
108
|
+
export task write(path, content)
|
|
109
|
+
require file("workspace")
|
|
110
|
+
require file("workspace/*")
|
|
111
|
+
let t be target(path)
|
|
112
|
+
must_exist(t)
|
|
113
|
+
when t["info"]["is_dir"]
|
|
114
|
+
raise("\"" + t["rel"] + "\" is a folder")
|
|
115
|
+
let s be when content == nothing then "" otherwise text(content)
|
|
116
|
+
write_file(t["real"], s)
|
|
117
|
+
give {"path": t["rel"], "is_dir": false, "lines": length(split(s, "\n"))}
|
|
118
|
+
|
|
119
|
+
export task rename(path, name)
|
|
120
|
+
require exec
|
|
121
|
+
require env("LAMPSON_*")
|
|
122
|
+
require env("OS")
|
|
123
|
+
require file("workspace")
|
|
124
|
+
require file("workspace/*")
|
|
125
|
+
let t be target(path)
|
|
126
|
+
must_exist(t)
|
|
127
|
+
when t["rel"] == "."
|
|
128
|
+
raise("the workspace root cannot be renamed")
|
|
129
|
+
let rel be join_rel(parent_of(t["rel"]), check_name(name))
|
|
130
|
+
when rel == t["rel"]
|
|
131
|
+
give {"path": rel, "is_dir": t["info"]["is_dir"]}
|
|
132
|
+
-- solo cambian mayúsculas: en un FS case-insensitive el destino "existe" → pasar por un temporal
|
|
133
|
+
when lower(rel) == lower(t["rel"])
|
|
134
|
+
let tmp be rel + ".lampson-rename-tmp"
|
|
135
|
+
sh("mv " + sh_quote(t["rel"]) + " " + sh_quote(tmp) + " && mv " + sh_quote(tmp) + " " + sh_quote(rel))
|
|
136
|
+
give {"path": rel, "is_dir": t["info"]["is_dir"]}
|
|
137
|
+
must_not_exist(rel)
|
|
138
|
+
sh("mv " + sh_quote(t["rel"]) + " " + sh_quote(rel))
|
|
139
|
+
give {"path": rel, "is_dir": t["info"]["is_dir"]}
|
|
140
|
+
|
|
141
|
+
export task move(path, to_dir)
|
|
142
|
+
require exec
|
|
143
|
+
require env("LAMPSON_*")
|
|
144
|
+
require env("OS")
|
|
145
|
+
require file("workspace")
|
|
146
|
+
require file("workspace/*")
|
|
147
|
+
let t be target(path)
|
|
148
|
+
must_exist(t)
|
|
149
|
+
when t["rel"] == "."
|
|
150
|
+
raise("the workspace root cannot be moved")
|
|
151
|
+
let d be target(to_dir)
|
|
152
|
+
must_be_dir(d["rel"], d["info"])
|
|
153
|
+
when t["info"]["is_dir"] and inside(t["rel"], d["rel"])
|
|
154
|
+
raise("cannot move a folder into itself")
|
|
155
|
+
let rel be join_rel(d["rel"], base_of(t["rel"]))
|
|
156
|
+
when rel == t["rel"]
|
|
157
|
+
give {"path": rel, "is_dir": t["info"]["is_dir"]}
|
|
158
|
+
must_not_exist(rel)
|
|
159
|
+
sh("mv " + sh_quote(t["rel"]) + " " + sh_quote(rel))
|
|
160
|
+
give {"path": rel, "is_dir": t["info"]["is_dir"]}
|
|
161
|
+
|
|
162
|
+
-- "b.ts" en `dir` → "b copy.ts", "b copy 2.ts"…; carpetas y dotfiles: "sub copy", ".env copy"
|
|
163
|
+
task copy_name(dir, base)
|
|
164
|
+
require file.read("workspace")
|
|
165
|
+
require file.read("workspace/*")
|
|
166
|
+
let stem be base
|
|
167
|
+
let ext be ""
|
|
168
|
+
let parts be split(base, ".")
|
|
169
|
+
when length(parts) > 1 and parts[0] != ""
|
|
170
|
+
set ext to "." + parts[length(parts) - 1]
|
|
171
|
+
set stem to slice(base, 0, length(base) - length(ext))
|
|
172
|
+
let n be 1
|
|
173
|
+
let cand be stem + " copy" + ext
|
|
174
|
+
while file_exists(c.ROOT + "/" + join_rel(dir, cand)) and n < 100
|
|
175
|
+
set n to n + 1
|
|
176
|
+
set cand to stem + " copy " + text(n) + ext
|
|
177
|
+
give cand
|
|
178
|
+
|
|
179
|
+
-- to_dir = nothing → duplicar al lado; otra carpeta → copiar ahí (mismo nombre, o "copy" si ya hay uno)
|
|
180
|
+
export task copy(path, to_dir)
|
|
181
|
+
require exec
|
|
182
|
+
require env("LAMPSON_*")
|
|
183
|
+
require env("OS")
|
|
184
|
+
require file("workspace")
|
|
185
|
+
require file("workspace/*")
|
|
186
|
+
let t be target(path)
|
|
187
|
+
must_exist(t)
|
|
188
|
+
when t["rel"] == "."
|
|
189
|
+
raise("the workspace root cannot be copied")
|
|
190
|
+
let dir be when to_dir == nothing then parent_of(t["rel"]) otherwise target(to_dir)["rel"]
|
|
191
|
+
must_be_dir(dir, file_info(c.ws(dir)))
|
|
192
|
+
when t["info"]["is_dir"] and inside(t["rel"], dir)
|
|
193
|
+
raise("cannot copy a folder into itself")
|
|
194
|
+
let base be base_of(t["rel"])
|
|
195
|
+
let rel be join_rel(dir, base)
|
|
196
|
+
when rel == t["rel"] or file_exists(c.ROOT + "/" + rel)
|
|
197
|
+
set rel to join_rel(dir, copy_name(dir, base))
|
|
198
|
+
sh("cp -r " + sh_quote(t["rel"]) + " " + sh_quote(rel))
|
|
199
|
+
give {"path": rel, "is_dir": t["info"]["is_dir"]}
|
|
200
|
+
|
|
201
|
+
export task remove(path)
|
|
202
|
+
require exec
|
|
203
|
+
require env("LAMPSON_*")
|
|
204
|
+
require env("OS")
|
|
205
|
+
require file("workspace")
|
|
206
|
+
require file("workspace/*")
|
|
207
|
+
let t be target(path)
|
|
208
|
+
must_exist(t)
|
|
209
|
+
when t["rel"] == "."
|
|
210
|
+
raise("the workspace root cannot be deleted")
|
|
211
|
+
when t["rel"] == ".git" or t["rel"] == ".lampson"
|
|
212
|
+
raise("\"" + t["rel"] + "\" is not deleted from the explorer")
|
|
213
|
+
sh("rm -rf " + sh_quote(t["rel"]))
|
|
214
|
+
give {"path": t["rel"], "is_dir": t["info"]["is_dir"]}
|
|
215
|
+
|
|
216
|
+
-- POST /api/fs: {op, path, name?, to?, content?} → {path, is_dir}
|
|
217
|
+
export task apply(b)
|
|
218
|
+
require exec
|
|
219
|
+
require env("LAMPSON_*")
|
|
220
|
+
require env("OS")
|
|
221
|
+
require file("workspace")
|
|
222
|
+
require file("workspace/*")
|
|
223
|
+
let op be when contains(b, "op") then lower(text(b["op"])) otherwise ""
|
|
224
|
+
let path be when contains(b, "path") then text(b["path"]) otherwise "."
|
|
225
|
+
let name be when contains(b, "name") then b["name"] otherwise nothing
|
|
226
|
+
-- (`to` es palabra reservada: `set x to`)
|
|
227
|
+
let dest be when contains(b, "to") then text(b["to"]) otherwise nothing
|
|
228
|
+
when op == "mkdir"
|
|
229
|
+
give mkdir(path, name)
|
|
230
|
+
when op == "create"
|
|
231
|
+
give create(path, name, when contains(b, "content") then b["content"] otherwise "")
|
|
232
|
+
when op == "write"
|
|
233
|
+
give write(path, when contains(b, "content") then b["content"] otherwise "")
|
|
234
|
+
when op == "rename"
|
|
235
|
+
give rename(path, name)
|
|
236
|
+
when op == "move"
|
|
237
|
+
give move(path, when dest == nothing then "." otherwise dest)
|
|
238
|
+
when op == "copy"
|
|
239
|
+
give copy(path, dest)
|
|
240
|
+
when op == "delete"
|
|
241
|
+
give remove(path)
|
|
242
|
+
raise("unknown op '" + op + "' (mkdir, create, write, rename, move, copy, delete)")
|