lampson 0.2.8 → 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.
Files changed (78) hide show
  1. package/.env.example +10 -1
  2. package/README.md +8 -3
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +111 -7
  5. package/cli.syn +143 -7
  6. package/edge.syn +26 -0
  7. package/hub.tpl.syn +127 -6
  8. package/lampson.ps1 +43 -2
  9. package/lampson.sh +40 -2
  10. package/lib/agents.syn +27 -6
  11. package/lib/approvals.syn +2 -2
  12. package/lib/auth.syn +274 -0
  13. package/lib/computer.syn +2000 -0
  14. package/lib/computer_judge.syn +410 -0
  15. package/lib/fs.syn +19 -3
  16. package/lib/git.syn +2 -2
  17. package/lib/judge.syn +78 -0
  18. package/lib/line.syn +2 -2
  19. package/lib/loop.syn +36 -4
  20. package/lib/lsp.syn +4 -4
  21. package/lib/mcp.syn +20 -4
  22. package/lib/md.syn +11 -11
  23. package/lib/origin.syn +67 -0
  24. package/lib/permission.syn +21 -2
  25. package/lib/prompt.syn +9 -1
  26. package/lib/provider.syn +70 -20
  27. package/lib/schedule.syn +12 -12
  28. package/lib/session.syn +5 -5
  29. package/lib/settings.syn +65 -1
  30. package/lib/skills.syn +22 -6
  31. package/lib/tools/bash.syn +1 -1
  32. package/lib/tools/common.syn +1 -1
  33. package/lib/tools/fetch.syn +98 -71
  34. package/lib/tools/grep.syn +1 -1
  35. package/lib/tools/memo.syn +3 -3
  36. package/lib/tools/proc.syn +38 -20
  37. package/lib/tools/qr.js +26 -0
  38. package/lib/tools/url.syn +5 -5
  39. package/lib/tools.syn +23 -2
  40. package/lib/trace.syn +2 -2
  41. package/lib/tree.syn +29 -13
  42. package/lib/update.syn +1 -1
  43. package/lib/workspaces.syn +202 -36
  44. package/package.json +3 -2
  45. package/public/apple-touch-icon.png +0 -0
  46. package/public/badge-96.png +0 -0
  47. package/public/css/code.css +48 -0
  48. package/public/css/hub.css +9 -0
  49. package/public/css/login.css +16 -0
  50. package/public/css/panel.css +30 -0
  51. package/public/css/tokens.css +1 -1
  52. package/public/hub.html +11 -1
  53. package/public/icon-192.png +0 -0
  54. package/public/icon-512.png +0 -0
  55. package/public/icon-maskable-512.png +0 -0
  56. package/public/icons-src/badge.svg +12 -0
  57. package/public/icons-src/icon-maskable.svg +15 -0
  58. package/public/icons-src/icon.svg +13 -0
  59. package/public/index.html +14 -3
  60. package/public/js/agents.js +1 -1
  61. package/public/js/chat.js +1 -1
  62. package/public/js/config.js +145 -2
  63. package/public/js/core.js +4 -2
  64. package/public/js/devices.js +80 -0
  65. package/public/js/editor.js +232 -0
  66. package/public/js/login.js +78 -0
  67. package/public/js/procs.js +1 -1
  68. package/public/js/pwa.js +5 -0
  69. package/public/js/tree.js +12 -4
  70. package/public/login.html +43 -0
  71. package/public/manifest.webmanifest +16 -0
  72. package/public/offline.html +24 -0
  73. package/public/sw.js +63 -0
  74. package/public/vendor/highlight.min.js +1343 -0
  75. package/public/vendor/qrcode.js +2297 -0
  76. package/skills/computer-use/SKILL.md +178 -0
  77. package/skills/lampson/SKILL.md +5 -0
  78. package/web.syn +67 -6
package/lib/session.syn CHANGED
@@ -68,7 +68,7 @@ export task list()
68
68
  let entries be list_dir(DIR)
69
69
  each e in entries
70
70
  when not e["is_dir"]
71
- let id be replace_text(e["name"], ".json", "")
71
+ let id be replace(e["name"], ".json", "")
72
72
  try
73
73
  let d be load(id)
74
74
  let title be when contains(d["meta"], "title") then d["meta"]["title"] otherwise ""
@@ -80,7 +80,7 @@ export task list()
80
80
  set out to out
81
81
  recover err
82
82
  give []
83
- give sort_by(out, (s) => 0 - number(replace_text(replace_text(s["id"], "-", ""), "test", "")))
83
+ give sort_by(out, (s) => 0 - number(replace(replace(s["id"], "-", ""), "test", "")))
84
84
 
85
85
  -- Búsqueda de texto en las sesiones de ESTE proyecto (título y contenido de los mensajes user/assistant):
86
86
  -- [{id, updated, title, snippet}] — snippet = el primer trozo donde aparece `q`. Vacío = todas (sin snippet).
@@ -111,7 +111,7 @@ export task search(q, limit)
111
111
  let pos be length(split(lc, needle)[0])
112
112
  let a be when pos > 60 then pos - 60 otherwise 0
113
113
  let b be when pos + length(needle) + 90 < length(c) then pos + length(needle) + 90 otherwise length(c)
114
- set hit to (when a > 0 then "…" otherwise "") + replace_text(replace_text(slice(c, a, b), "\r", ""), "\n", " ") + (when b < length(c) then "…" otherwise "")
114
+ set hit to (when a > 0 then "…" otherwise "") + replace(replace(slice(c, a, b), "\r", ""), "\n", " ") + (when b < length(c) then "…" otherwise "")
115
115
  recover err
116
116
  set hit to nothing
117
117
  when hit != nothing
@@ -132,7 +132,7 @@ export task delete(id)
132
132
  let files be [path_of(id), ".lampson/trace/" + id + ".log", ".lampson/trace/" + id + ".last"]
133
133
  each f in files
134
134
  when env("OS", "") == "Windows_NT"
135
- run("cmd", ["/c", "del", "/q", replace_text(f, "/", "\\")], 10)
135
+ run("cmd", ["/c", "del", "/q", replace(f, "/", "\\")], 10)
136
136
  otherwise
137
137
  run("rm", ["-f", f], 10)
138
138
  give "borrada " + id
@@ -141,7 +141,7 @@ export task delete(id)
141
141
  export task title_of(messages)
142
142
  each m in messages
143
143
  when m["role"] == "user"
144
- let t be replace_text(m["content"], "\n", " ")
144
+ let t be replace(m["content"], "\n", " ")
145
145
  when length(t) > 60
146
146
  give slice(t, 0, 60) + "…"
147
147
  give t
package/lib/settings.syn CHANGED
@@ -41,6 +41,12 @@ export task load()
41
41
  export task save(doc)
42
42
  require file(".lampson")
43
43
  require file(".lampson/*")
44
+ require env("LAMPSON_*")
45
+ -- LAMPSON_NO_CONFIG=1 (tests): NUNCA escribir el archivo real. Como load() devuelve un doc en blanco,
46
+ -- cualquier save() (remember_command al tipear /exit en tty_test, /verbose, set_key, set_default…)
47
+ -- pisaba provider y keys del usuario (pasó el 2026-09-05: tty_test dejó config.json vacío).
48
+ when env("LAMPSON_NO_CONFIG", "") == "1"
49
+ give false
44
50
  write_file(cfg_path(), json_encode(doc))
45
51
  give true
46
52
 
@@ -79,7 +85,11 @@ export task set_default(name, model)
79
85
 
80
86
  -- valores de configuración generales (rueda de la UI): tz, public_url, webhook_url, webhook_secret…
81
87
  -- Vacío = borrar la clave. Nunca se devuelven secretos al navegador: `values()` marca webhook_secret como has_*.
82
- export let KEYS be ["tz", "public_url", "webhook_url", "webhook_secret", "idle_hours"]
88
+ -- computer_*: la tool computer_use (lib/computer.syn) — "1" enciende; max_tabs y grant_profile son sus knobs.
89
+ -- judge = off | jev | laya: el juez de decisión de Lampson (lib/judge.syn), una credencial propia que usa computer
90
+ -- use y puede usar cualquier otra parte; judge_model = con laya, la carpeta del checkpoint; con jev, un id para
91
+ -- pinear (opcional). computer_judge / computer_judge_model: los nombres del primer día (2026-09-28), solo se leen.
92
+ export let KEYS be ["tz", "public_url", "webhook_url", "webhook_secret", "idle_hours", "computer_use", "computer_max_tabs", "computer_grant_profile", "judge", "judge_model", "computer_judge", "computer_judge_model"]
83
93
 
84
94
  -- el archivo REAL, ignorando LAMPSON_NO_CONFIG: escribir con el doc en blanco de los tests pisaría provider/keys
85
95
  -- (pasó el 2026-08-29 probando la rueda con un web de test)
@@ -131,3 +141,57 @@ export task values()
131
141
  set out[k] to e
132
142
  set out[k + "_from_env"] to true
133
143
  give out
144
+
145
+ -- el juez de computer use como ENTORNO para el runtime: Synsema lee SYNSEMA_JUDGE_* y TYPESAFE_API_KEY del proceso
146
+ -- (o del .env) al arrancar, y un programa no puede cambiarlos en caliente. Lo usan quienes lanzan procesos: el
147
+ -- workspace web (workspaces.start_ws) y la terminal (cli.syn ensure → lampson.ps1|sh). {} = no tocar nada.
148
+ -- La key de TypeSafe se guarda con set_key("typesafe", …) como las de los proveedores.
149
+ export task judge_env()
150
+ require file.read(".lampson")
151
+ require file.read(".lampson/*")
152
+ require env("LAMPSON_*")
153
+ let js be judge_setting()
154
+ let mode be lower(trim(text(js["mode"])))
155
+ let model be trim(text(js["model"]))
156
+ when mode == "jev" or mode == "typesafe"
157
+ let out be {"SYNSEMA_JUDGE_PROVIDER": "typesafe"}
158
+ let k be raw_key("typesafe")
159
+ when k != ""
160
+ set out["TYPESAFE_API_KEY"] to k
161
+ when model != ""
162
+ set out["SYNSEMA_JUDGE_MODEL"] to model
163
+ give out
164
+ when mode == "laya" or mode == "local"
165
+ let out be {"SYNSEMA_JUDGE_PROVIDER": "laya"}
166
+ when model != ""
167
+ set out["SYNSEMA_JUDGE_MODEL"] to model
168
+ give out
169
+ give {}
170
+
171
+ -- el juez configurado → {mode, model, from_env}: `judge` / `judge_model` (LAMPSON_JUDGE en .env gana), y si no, los
172
+ -- nombres viejos computer_judge / computer_judge_model
173
+ export task judge_setting()
174
+ require file.read(".lampson")
175
+ require file.read(".lampson/*")
176
+ require env("LAMPSON_*")
177
+ let v be values()
178
+ let m be trim(text(v["judge"]))
179
+ let mo be trim(text(v["judge_model"]))
180
+ when m == ""
181
+ set m to trim(text(v["computer_judge"]))
182
+ when mo == ""
183
+ set mo to trim(text(v["computer_judge_model"]))
184
+ give {"mode": m, "model": mo, "from_env": contains(v, "judge_from_env")}
185
+
186
+ -- guardar el juez con los nombres nuevos y borrar los viejos (una sola fuente de verdad)
187
+ export task set_judge(mode, model)
188
+ require file(".lampson")
189
+ require file(".lampson/*")
190
+ require env("LAMPSON_*")
191
+ set_value("judge", when mode == "off" then "" otherwise mode)
192
+ when model != nothing
193
+ set_value("judge_model", text(model))
194
+ set_value("computer_judge", "")
195
+ when model != nothing
196
+ set_value("computer_judge_model", "")
197
+ give true
package/lib/skills.syn CHANGED
@@ -37,9 +37,12 @@ let ROOTS be [
37
37
  {"dir": "workspace/.lampson/skills", "source": "local"}
38
38
  ]
39
39
 
40
+ use "./computer.syn" as computer
41
+
40
42
  task frontmatter(md)
41
- -- devuelve {name, description, body}; sin frontmatter → name vacío
42
- let out be {"name": "", "description": "", "body": md}
43
+ -- devuelve {name, description, requires, body}; sin frontmatter → name vacío.
44
+ -- `requires: computer_use` (skills del harness): la skill solo entra al índice si esa tool está disponible
45
+ let out be {"name": "", "description": "", "requires": "", "body": md}
43
46
  when not starts_with(md, "---")
44
47
  give out
45
48
  let rest be slice(md, 3, length(md))
@@ -54,10 +57,13 @@ task frontmatter(md)
54
57
  set out["name"] to trim(slice(l, 5, length(l)))
55
58
  otherwise when starts_with(l, "description:")
56
59
  set out["description"] to trim(slice(l, 12, length(l)))
60
+ otherwise when starts_with(l, "requires:")
61
+ set out["requires"] to trim(slice(l, 9, length(l)))
57
62
  set out["body"] to trim(body)
58
63
  give out
59
64
 
60
- task scan_root(root, acc)
65
+ -- hidden = tools que NO están disponibles: una skill con `requires:` de una de ellas no se lista
66
+ task scan_root(root, acc, hidden)
61
67
  let out be acc
62
68
  try
63
69
  each e in list_dir(root["dir"])
@@ -66,7 +72,8 @@ task scan_root(root, acc)
66
72
  try
67
73
  let fm be frontmatter(read_file(path))
68
74
  let name be when fm["name"] == "" then e["name"] otherwise fm["name"]
69
- set out[name] to {"name": name, "description": fm["description"], "path": path, "source": root["source"]}
75
+ when not contains(hidden, fm["requires"])
76
+ set out[name] to {"name": name, "description": fm["description"], "path": path, "source": root["source"]}
70
77
  recover err
71
78
  set out to out
72
79
  recover err
@@ -75,15 +82,20 @@ task scan_root(root, acc)
75
82
 
76
83
  -- índice: map name → {name, description, path, source}
77
84
  export task index()
85
+ require exec
86
+ require time
87
+ require env("LAMPSON_*")
88
+ require env("OS")
78
89
  require file.read("skills")
79
90
  require file.read("skills/*")
80
91
  require file.read("workspace")
81
92
  require file.read("workspace/*")
82
93
  require file.read(".lampson")
83
94
  require file.read(".lampson/*")
95
+ let hidden be when computer.available() then [] otherwise ["computer_use"]
84
96
  let acc be {}
85
97
  each r in ROOTS
86
- set acc to scan_root(r, acc)
98
+ set acc to scan_root(r, acc, hidden)
87
99
  give acc
88
100
 
89
101
  -- sección del system prompt
@@ -98,6 +110,10 @@ export task prompt_section(idx)
98
110
  give join(lines, "\n")
99
111
 
100
112
  export task load(name)
113
+ require exec
114
+ require time
115
+ require env("LAMPSON_*")
116
+ require env("OS")
101
117
  require file.read("skills")
102
118
  require file.read("skills/*")
103
119
  require file.read("workspace")
@@ -149,7 +165,7 @@ task ensure_global_mount()
149
165
  run(shell_exe(), ["-c", when is_win then win otherwise unix], 30, {"cwd": "."})
150
166
 
151
167
  task tail_out(s)
152
- let t be strip_ansi(replace_text(s, "\r", ""))
168
+ let t be strip_ansi(replace(s, "\r", ""))
153
169
  when length(t) > 3000
154
170
  give slice(t, length(t) - 3000, length(t))
155
171
  give t
@@ -27,7 +27,7 @@ export task shell_config()
27
27
  let SERVER_RE be ".*((npm|pnpm|yarn|bun)( run)? (dev|start|serve|watch|preview)|npx (next|vite|nodemon|serve|http-server)|next (dev|start)|vite( |$)|nodemon|synsema serve|uvicorn|gunicorn|flask run|manage\\.py runserver|python3? -m http\\.server|rails s(erver)?|php -S|ng serve|cargo (run|watch)|go run|dotnet (run|watch)|tail -f).*"
28
28
 
29
29
  export task looks_like_server(command)
30
- let cmd be trim(lower(replace_text(command, "\n", " ")))
30
+ let cmd be trim(lower(replace(command, "\n", " ")))
31
31
  when matches(cmd, "timeout [0-9]+.*")
32
32
  give false
33
33
  give matches(cmd, SERVER_RE)
@@ -20,7 +20,7 @@ task has_dotdot(p)
20
20
 
21
21
  -- path del modelo → path real bajo workspace/
22
22
  export task ws(path)
23
- let p be when path == nothing then "." otherwise replace_text(text(path), "\\", "/")
23
+ let p be when path == nothing then "." otherwise replace(text(path), "\\", "/")
24
24
  let stripping be true
25
25
  while stripping
26
26
  when starts_with(p, "./")
@@ -1,10 +1,10 @@
1
1
  -- lib/tools/fetch.syn — traer una página web o una respuesta HTTP como Markdown/texto, sin ruido
2
2
  --
3
3
  -- Antes (2026-09-02) el modelo hacía `bash curl <url>` y recibía HTML crudo: head, CSS, nav, scripts —
4
- -- hasta 10k chars por llamada de puro ruido (synsema.com: 26,8 KB de HTML vs 2,5 KB de Markdown).
4
+ -- hasta 10k chars por llamada de puro ruido (synsema.org: 26,8 KB de HTML vs 2,5 KB de Markdown).
5
5
  -- Esta tool combina lo mejor de los tres harnesses de referencia:
6
6
  -- * opencode webfetch.ts — negociación `Accept` con q-values por formato (markdown/text/html): un sitio
7
- -- que sirve Markdown (synsema.com) lo entrega directo, sin convertir.
7
+ -- que sirve Markdown (synsema.org) lo entrega directo, sin convertir.
8
8
  -- * deepseek web-fetch-http — transporte: redirects MANUALES (http_get no los sigue) hasta 5 saltos,
9
9
  -- revalidando cada salto con la política de hosts; tope de bytes; Content-Type clasificado (html /
10
10
  -- texto / binario); cabecera "url · HTTP status · tipo · tamaño" y pie explícito al truncar.
@@ -126,27 +126,27 @@ let NAMED be {"nbsp": " ", "lt": "<", "gt": ">", "quot": "\"", "apos": "'", "cop
126
126
  export task decode_entities(s)
127
127
  let out be s
128
128
  -- numéricas: &#233; &#xE9;
129
- each m in find_all(out, "&#[0-9]{1,7};|&#[xX][0-9A-Fa-f]{1,6};")
129
+ each m in regex_find_all(out, "&#[0-9]{1,7};|&#[xX][0-9A-Fa-f]{1,6};")
130
130
  let inner be slice(m, 2, length(m) - 1)
131
131
  let cp be when starts_with(lower(inner), "x") then hex_to_int(slice(inner, 1, length(inner))) otherwise floor(number(inner))
132
- set out to replace_text(out, m, cp_text(cp))
132
+ set out to replace(out, m, cp_text(cp))
133
133
  -- con nombre (las comunes; el resto se deja tal cual)
134
- each m in find_all(out, "&[A-Za-z][A-Za-z0-9]{1,8};")
134
+ each m in regex_find_all(out, "&[A-Za-z][A-Za-z0-9]{1,8};")
135
135
  let name be slice(m, 1, length(m) - 1)
136
136
  when contains(NAMED, name)
137
- set out to replace_text(out, m, NAMED[name])
138
- give replace_text(out, "&amp;", "&")
137
+ set out to replace(out, m, NAMED[name])
138
+ give replace(out, "&amp;", "&")
139
139
 
140
140
  task strip_tags(s)
141
- give replace_re(s, "(?s)<[^>]*>", "")
141
+ give regex_replace(s, "(?s)<[^>]*>", "")
142
142
 
143
143
  -- texto de un fragmento inline (título, texto de un link, celda): sin tags, entidades, espacios colapsados
144
144
  task inline_text(s)
145
- give trim(replace_re(decode_entities(strip_tags(s)), "\\s+", " "))
145
+ give trim(regex_replace(decode_entities(strip_tags(s)), "\\s+", " "))
146
146
 
147
147
  export task title_of(html)
148
148
  -- capture con grupos devuelve una LISTA de grupos (verificado): [0] es el título
149
- let t be capture(html, "(?is)<title[^>]*>(.*?)</title>")
149
+ let t be regex_capture(html, "(?is)<title[^>]*>(.*?)</title>")
150
150
  when t == nothing
151
151
  give ""
152
152
  give inline_text(t[0])
@@ -157,14 +157,14 @@ export task title_of(html)
157
157
  task pull_pre(h, plain)
158
158
  let blocks be []
159
159
  let out be h
160
- each e in enumerate(find_all(h, "(?is)<pre\\b.*?</pre>"))
160
+ each e in enumerate(regex_find_all(h, "(?is)<pre\\b.*?</pre>"))
161
161
  let blk be e["item"]
162
- let langm be capture(blk, "(?i)language-([A-Za-z0-9_+#-]+)")
162
+ let langm be regex_capture(blk, "(?i)language-([A-Za-z0-9_+#-]+)")
163
163
  let lang be when langm == nothing then nothing otherwise langm[0]
164
- let innerm be capture(blk, "(?is)<pre\\b[^>]*>(.*?)</pre>")
164
+ let innerm be regex_capture(blk, "(?is)<pre\\b[^>]*>(.*?)</pre>")
165
165
  let inner be when innerm == nothing then "" otherwise innerm[0]
166
166
  let code be decode_entities(strip_tags(inner))
167
- set code to replace_text(code, "\r\n", "\n")
167
+ set code to replace(code, "\r\n", "\n")
168
168
  -- quitar un salto inicial/final (el HTML suele meterlos), no la indentación
169
169
  when starts_with(code, "\n")
170
170
  set code to slice(code, 1, length(code))
@@ -172,47 +172,47 @@ task pull_pre(h, plain)
172
172
  set code to slice(code, 0, length(code) - 1)
173
173
  let fenced be when plain then "\n\n" + code + "\n\n" otherwise "\n\n```" + (when lang != nothing then text(lang) otherwise "") + "\n" + code + "\n```\n\n"
174
174
  set blocks to append(blocks, fenced)
175
- set out to replace_text(out, blk, "%%LPRE" + text(e["index"]) + "%%")
175
+ set out to replace(out, blk, "%%LPRE" + text(e["index"]) + "%%")
176
176
  give {"html": out, "blocks": blocks}
177
177
 
178
178
  task push_pre(s, blocks)
179
179
  let out be s
180
180
  each e in enumerate(blocks)
181
- set out to replace_text(out, "%%LPRE" + text(e["index"]) + "%%", e["item"])
181
+ set out to replace(out, "%%LPRE" + text(e["index"]) + "%%", e["item"])
182
182
  give out
183
183
 
184
184
  -- <ol> → "1. …" (find_all + reemplazo literal: replace_re no puede contar)
185
185
  task number_lists(h)
186
186
  let out be h
187
- each blk in find_all(h, "(?is)<ol\\b[^>]*>.*?</ol>")
187
+ each blk in regex_find_all(h, "(?is)<ol\\b[^>]*>.*?</ol>")
188
188
  let parts be split(blk, "<li")
189
189
  let items be []
190
190
  each e in enumerate(parts)
191
191
  when e["index"] > 0
192
192
  -- "<li" + [atributos]> contenido </li>
193
- let body be replace_re(e["item"], "(?s)^[^>]*>", "")
194
- set body to replace_re(body, "(?is)</li>.*$", "")
193
+ let body be regex_replace(e["item"], "(?s)^[^>]*>", "")
194
+ set body to regex_replace(body, "(?is)</li>.*$", "")
195
195
  set items to append(items, text(length(items) + 1) + ". " + trim(body))
196
- set out to replace_text(out, blk, "\n\n" + join(items, "\n") + "\n\n")
196
+ set out to replace(out, blk, "\n\n" + join(items, "\n") + "\n\n")
197
197
  give out
198
198
 
199
199
  -- <table> → tabla GFM (sin colspan; hermes/deepseek también los ignoran)
200
200
  task tables(h)
201
201
  let out be h
202
- each blk in find_all(h, "(?is)<table\\b.*?</table>")
202
+ each blk in regex_find_all(h, "(?is)<table\\b.*?</table>")
203
203
  let rows be []
204
204
  let first_is_head be false
205
205
  each e in enumerate(split(blk, "<tr"))
206
206
  when e["index"] > 0
207
- let row be replace_re(e["item"], "(?s)^[^>]*>", "")
208
- set row to replace_re(row, "(?is)</tr>.*$", "")
207
+ let row be regex_replace(e["item"], "(?s)^[^>]*>", "")
208
+ set row to regex_replace(row, "(?is)</tr>.*$", "")
209
209
  let cells be []
210
- let row_td be replace_re(row, "(?i)<th\\b", "<td")
210
+ let row_td be regex_replace(row, "(?i)<th\\b", "<td")
211
211
  each ce in enumerate(split(row_td, "<td"))
212
212
  when ce["index"] > 0
213
- let cell be replace_re(ce["item"], "(?s)^[^>]*>", "")
214
- set cell to replace_re(cell, "(?is)</t[dh]>.*$", "")
215
- set cells to append(cells, replace_text(inline_text(cell), "|", "\\|"))
213
+ let cell be regex_replace(ce["item"], "(?s)^[^>]*>", "")
214
+ set cell to regex_replace(cell, "(?is)</t[dh]>.*$", "")
215
+ set cells to append(cells, replace(inline_text(cell), "|", "\\|"))
216
216
  when length(cells) > 0
217
217
  when length(rows) == 0 and contains(lower(row), "<th")
218
218
  set first_is_head to true
@@ -230,17 +230,17 @@ task tables(h)
230
230
  when e["index"] > 0
231
231
  set with_sep to append(with_sep, e["item"])
232
232
  set rows to with_sep
233
- set out to replace_text(out, blk, "\n\n" + join(rows, "\n") + "\n\n")
233
+ set out to replace(out, blk, "\n\n" + join(rows, "\n") + "\n\n")
234
234
  give out
235
235
 
236
236
  -- <a href> → [texto](url absoluta); en plain, solo el texto
237
237
  task links(h, base, plain)
238
238
  let out be h
239
- each a in find_all(h, "(?is)<a\\b[^>]*>.*?</a>")
240
- let inner be capture(a, "(?is)<a\\b[^>]*>(.*?)</a>")
239
+ each a in regex_find_all(h, "(?is)<a\\b[^>]*>.*?</a>")
240
+ let inner be regex_capture(a, "(?is)<a\\b[^>]*>(.*?)</a>")
241
241
  let txt be when inner == nothing then "" otherwise inline_text(inner[0])
242
- let href be capture(a, "(?is)<a\\b[^>]*\\shref\\s*=\\s*[\"']([^\"']*)[\"']")
243
- let abs be when href == nothing then nothing otherwise u.resolve(base, href[0])
242
+ let href be regex_capture(a, "(?is)<a\\b[^>]*\\shref\\s*=\\s*[\"']([^\"']*)[\"']")
243
+ let abs be when href == nothing then nothing otherwise unwrap_ddg(u.resolve(base, href[0]))
244
244
  let repl be txt
245
245
  -- anclas internas (#x) sin destino real: "Skip to content", el "§"/"#" de cada heading → fuera
246
246
  when abs == nothing
@@ -248,9 +248,36 @@ task links(h, base, plain)
248
248
  set repl to ""
249
249
  when not plain and abs != nothing and txt != ""
250
250
  set repl to when abs == txt then "<" + abs + ">" otherwise "[" + txt + "](" + abs + ")"
251
- set out to replace_text(out, a, repl)
251
+ set out to replace(out, a, repl)
252
252
  give out
253
253
 
254
+ -- DuckDuckGo (html.duckduckgo.com — buscar en la web SIN API key ni navegador) envuelve cada resultado en
255
+ -- //duckduckgo.com/l/?uddg=<destino codificado>&rut=…: el modelo necesita el destino real (para traerlo con fetch
256
+ -- o abrirlo con computer_use), no el redirect. Sin builtin de percent-decoding: se decodifica a bytes y luego UTF-8.
257
+ task unwrap_ddg(url)
258
+ when url == nothing
259
+ give nothing
260
+ when not contains(url, "duckduckgo.com/l/?")
261
+ give url
262
+ let m be regex_capture(url, "[?&]uddg=([^&]*)")
263
+ when m == nothing
264
+ give url
265
+ give pct_decode(m[0])
266
+
267
+ task pct_decode(s)
268
+ let out be bytes("")
269
+ let i be 0
270
+ let n be length(s)
271
+ while i < n
272
+ let ch be slice(s, i, i + 1)
273
+ when ch == "%" and i + 3 <= n and matches(slice(s, i + 1, i + 3), "[0-9A-Fa-f]{2}")
274
+ set out to out + bytes(slice(s, i + 1, i + 3), "hex")
275
+ set i to i + 3
276
+ otherwise
277
+ set out to out + bytes(when ch == "+" then " " otherwise ch)
278
+ set i to i + 1
279
+ give decode(out, "utf8_lossy")
280
+
254
281
  -- blockquote: marcadores y luego prefijo "> " línea por línea
255
282
  task quote_lines(s)
256
283
  when not contains(s, "%%LBQ%%")
@@ -261,7 +288,7 @@ task quote_lines(s)
261
288
  let line be l
262
289
  let opens be length(split(line, "%%LBQ%%")) - 1
263
290
  let closes be length(split(line, "%%/LBQ%%")) - 1
264
- set line to replace_text(replace_text(line, "%%LBQ%%", ""), "%%/LBQ%%", "")
291
+ set line to replace(replace(line, "%%LBQ%%", ""), "%%/LBQ%%", "")
265
292
  when inq or opens > 0
266
293
  when trim(line) != ""
267
294
  set line to "> " + trim(line)
@@ -273,12 +300,12 @@ task quote_lines(s)
273
300
  give join(out, "\n")
274
301
 
275
302
  export task to_markdown(html, base, plain)
276
- let h be replace_text(text(html), "\r\n", "\n")
277
- set h to replace_re(h, "(?s)<!--.*?-->", "")
303
+ let h be replace(text(html), "\r\n", "\n")
304
+ set h to regex_replace(h, "(?s)<!--.*?-->", "")
278
305
  -- ruido: sin contenido para el modelo (un patrón por tag: el motor Rust no tiene backreferences)
279
306
  -- (header también: en docs/GitHub es el menú del sitio; el título de la página va aparte en `title:`)
280
307
  each tg in ["script", "style", "noscript", "svg", "iframe", "template", "head", "nav", "header", "aside", "footer", "dialog", "select", "button", "canvas", "video", "audio", "object", "embed"]
281
- set h to replace_re(h, "(?is)<" + tg + "\\b.*?</" + tg + "\\s*>", "")
308
+ set h to regex_replace(h, "(?is)<" + tg + "\\b.*?</" + tg + "\\s*>", "")
282
309
  let pre be pull_pre(h, plain)
283
310
  set h to pre["html"]
284
311
  -- links primero (una celda o un ítem con <a> conserva el link); listas numeradas y tablas antes de
@@ -287,58 +314,58 @@ export task to_markdown(html, base, plain)
287
314
  set h to number_lists(h)
288
315
  set h to tables(h)
289
316
  -- imágenes: alt como marca; sin alt, nada (base64 jamás llega al modelo)
290
- set h to replace_re(h, "(?is)<img\\b[^>]*\\salt\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>", "[image: \\1]")
291
- set h to replace_re(h, "(?is)<img\\b[^>]*>", "")
317
+ set h to regex_replace(h, "(?is)<img\\b[^>]*\\salt\\s*=\\s*[\"']([^\"']+)[\"'][^>]*>", "[image: \\1]")
318
+ set h to regex_replace(h, "(?is)<img\\b[^>]*>", "")
292
319
  each lvl in ["1", "2", "3", "4", "5", "6"]
293
320
  let hashes be when plain then "" otherwise slice("######", 0, floor(number(lvl))) + " "
294
- set h to replace_re(h, "(?is)<h" + lvl + "\\b[^>]*>(.*?)</h" + lvl + ">", "\n\n" + hashes + "\\1\n\n")
295
- set h to replace_re(h, "(?i)<br\\s*/?>", "\n")
296
- set h to replace_re(h, "(?i)<hr\\b[^>]*>", when plain then "\n\n" otherwise "\n\n---\n\n")
297
- set h to replace_re(h, "(?is)<blockquote\\b[^>]*>", "\n\n%%LBQ%%")
298
- set h to replace_re(h, "(?i)</blockquote>", "%%/LBQ%%\n\n")
299
- set h to replace_re(h, "(?i)<li\\b[^>]*>", "\n- ")
300
- set h to replace_re(h, "(?i)</li>", "")
301
- set h to replace_re(h, "(?i)</(ul|ol|dl)>", "\n")
302
- set h to replace_re(h, "(?i)<(ul|ol|dl)\\b[^>]*>", "\n")
303
- set h to replace_re(h, "(?i)<dt\\b[^>]*>", "\n**")
304
- set h to replace_re(h, "(?i)</dt>", "**\n")
305
- set h to replace_re(h, "(?i)<dd\\b[^>]*>", "\n ")
321
+ set h to regex_replace(h, "(?is)<h" + lvl + "\\b[^>]*>(.*?)</h" + lvl + ">", "\n\n" + hashes + "\\1\n\n")
322
+ set h to regex_replace(h, "(?i)<br\\s*/?>", "\n")
323
+ set h to regex_replace(h, "(?i)<hr\\b[^>]*>", when plain then "\n\n" otherwise "\n\n---\n\n")
324
+ set h to regex_replace(h, "(?is)<blockquote\\b[^>]*>", "\n\n%%LBQ%%")
325
+ set h to regex_replace(h, "(?i)</blockquote>", "%%/LBQ%%\n\n")
326
+ set h to regex_replace(h, "(?i)<li\\b[^>]*>", "\n- ")
327
+ set h to regex_replace(h, "(?i)</li>", "")
328
+ set h to regex_replace(h, "(?i)</(ul|ol|dl)>", "\n")
329
+ set h to regex_replace(h, "(?i)<(ul|ol|dl)\\b[^>]*>", "\n")
330
+ set h to regex_replace(h, "(?i)<dt\\b[^>]*>", "\n**")
331
+ set h to regex_replace(h, "(?i)</dt>", "**\n")
332
+ set h to regex_replace(h, "(?i)<dd\\b[^>]*>", "\n ")
306
333
  -- énfasis vacíos decorativos (<i></i> como puntos de una barra) serían asteriscos sueltos
307
- set h to replace_re(h, "(?is)<(i|em|b|strong)\\b[^>]*>\\s*</(i|em|b|strong)>", "")
334
+ set h to regex_replace(h, "(?is)<(i|em|b|strong)\\b[^>]*>\\s*</(i|em|b|strong)>", "")
308
335
  when not plain
309
- set h to replace_re(h, "(?is)<(b|strong)\\b[^>]*>(.*?)</(b|strong)>", "**\\2**")
310
- set h to replace_re(h, "(?is)<(i|em)\\b[^>]*>(.*?)</(i|em)>", "*\\2*")
311
- set h to replace_re(h, "(?is)<code\\b[^>]*>(.*?)</code>", "`\\1`")
312
- set h to replace_re(h, "(?i)</(p|div|section|article|main|header|figure|figcaption|details|summary|form|fieldset|address|tr|dd)>", "\n\n")
313
- set h to replace_re(h, "(?i)<(p|div|section|article|main|header|figure|figcaption|details|summary|form|fieldset|address)\\b[^>]*>", "\n")
336
+ set h to regex_replace(h, "(?is)<(b|strong)\\b[^>]*>(.*?)</(b|strong)>", "**\\2**")
337
+ set h to regex_replace(h, "(?is)<(i|em)\\b[^>]*>(.*?)</(i|em)>", "*\\2*")
338
+ set h to regex_replace(h, "(?is)<code\\b[^>]*>(.*?)</code>", "`\\1`")
339
+ set h to regex_replace(h, "(?i)</(p|div|section|article|main|header|figure|figcaption|details|summary|form|fieldset|address|tr|dd)>", "\n\n")
340
+ set h to regex_replace(h, "(?i)<(p|div|section|article|main|header|figure|figcaption|details|summary|form|fieldset|address)\\b[^>]*>", "\n")
314
341
  set h to strip_tags(h)
315
342
  set h to decode_entities(h)
316
343
  -- énfasis vacío que quedó de tags sin texto
317
- set h to replace_re(h, "\\*\\*\\s*\\*\\*", "")
318
- set h to replace_re(h, "``", "")
344
+ set h to regex_replace(h, "\\*\\*\\s*\\*\\*", "")
345
+ set h to regex_replace(h, "``", "")
319
346
  -- espacios: colapsar (los <pre> están a salvo en sus marcadores)
320
- set h to replace_text(h, cp_text(160), " ")
321
- set h to replace_re(h, "[ \\t\\f\\v]+", " ")
322
- set h to replace_re(h, "(?m)^ +", "")
323
- set h to replace_re(h, "(?m) +$", "")
324
- set h to replace_re(h, "\\n{3,}", "\n\n")
347
+ set h to replace(h, cp_text(160), " ")
348
+ set h to regex_replace(h, "[ \\t\\f\\v]+", " ")
349
+ set h to regex_replace(h, "(?m)^ +", "")
350
+ set h to regex_replace(h, "(?m) +$", "")
351
+ set h to regex_replace(h, "\\n{3,}", "\n\n")
325
352
  set h to quote_lines(h)
326
353
  set h to push_pre(h, pre["blocks"])
327
- set h to replace_re(h, "\\n{3,}", "\n\n")
354
+ set h to regex_replace(h, "\\n{3,}", "\n\n")
328
355
  give trim(h)
329
356
 
330
357
  -- Markdown servido por el sitio: solo sacar imágenes base64 (hermes convert_base64_images_to_links)
331
358
  export task clean_markdown(md)
332
- let out be replace_re(text(md), "!\\[([^\\]]*)\\]\\(\\s*data:image/[^)]*\\)", "[image: \\1]")
333
- set out to replace_re(out, "data:image/[a-z+]+;base64,[A-Za-z0-9+/=]{40,}", "[image]")
334
- give trim(replace_text(out, "\r\n", "\n"))
359
+ let out be regex_replace(text(md), "!\\[([^\\]]*)\\]\\(\\s*data:image/[^)]*\\)", "[image: \\1]")
360
+ set out to regex_replace(out, "data:image/[a-z+]+;base64,[A-Za-z0-9+/=]{40,}", "[image]")
361
+ give trim(replace(out, "\r\n", "\n"))
335
362
 
336
363
  -- href del <link rel="alternate" type="text/markdown">, resuelto; nothing si no hay
337
364
  export task markdown_alternate(html, base)
338
- each l in find_all(text(html), "(?is)<link\\b[^>]*>")
365
+ each l in regex_find_all(text(html), "(?is)<link\\b[^>]*>")
339
366
  let ll be lower(l)
340
367
  when contains(ll, "alternate") and (contains(ll, "type=\"text/markdown\"") or contains(ll, "type='text/markdown'") or contains(ll, "type=\"text/x-markdown\""))
341
- let href be capture(l, "(?is)\\shref\\s*=\\s*[\"']([^\"']*)[\"']")
368
+ let href be regex_capture(l, "(?is)\\shref\\s*=\\s*[\"']([^\"']*)[\"']")
342
369
  when href != nothing
343
370
  give u.resolve(base, href[0])
344
371
  give nothing
@@ -563,7 +590,7 @@ export task tool(url, format, max_chars, timeout)
563
590
 
564
591
  export let SPEC be {
565
592
  "name": "fetch",
566
- "description": "Fetch a web page or HTTP API response and return it as Markdown (default), plain text or raw HTML — scripts, styles, navigation and footers stripped, entities decoded, links made absolute, code blocks and tables kept. Use it instead of bash+curl whenever you will READ the result (docs, READMEs, issues, changelogs, API JSON): it asks the server for Markdown first and returns about a tenth of the characters of raw HTML. Follows up to 5 redirects. Pages longer than max_chars come back as head+tail with the full text saved to a file you can page with read (offset/limit) or grep — never fetch the same URL twice in a turn; repeats within 20 minutes are served from that file anyway. Binary content (images, PDF, archives) is reported, not returned. Private/loopback hosts (localhost, 10.x, 192.168.x, *.internal) ask the user first — fine for checking your own dev server; cloud metadata endpoints are always refused. Cite the URL when you use its content.",
593
+ "description": "Fetch a web page or HTTP API response and return it as Markdown (default), plain text or raw HTML — scripts, styles, navigation and footers stripped, entities decoded, links made absolute, code blocks and tables kept. Use it instead of bash+curl whenever you will READ the result (docs, READMEs, issues, changelogs, API JSON): it asks the server for Markdown first and returns about a tenth of the characters of raw HTML. Follows up to 5 redirects. Pages longer than max_chars come back as head+tail with the full text saved to a file you can page with read (offset/limit) or grep — never fetch the same URL twice in a turn; repeats within 20 minutes are served from that file anyway. Binary content (images, PDF, archives) is reported, not returned. Private/loopback hosts (localhost, 10.x, 192.168.x, *.internal) ask the user first — fine for checking your own dev server; cloud metadata endpoints are always refused. Cite the URL when you use its content. To SEARCH the web (no API key, no browser): fetch https://html.duckduckgo.com/html/?q=<url-encoded query> — the results come back as Markdown with the real target links; refine the query as needed, then fetch the page you chose.",
567
594
  "parameters": {"type": "object", "properties": {
568
595
  "url": {"type": "string", "description": "Absolute http(s) URL"},
569
596
  "format": {"type": "string", "enum": ["markdown", "text", "html"], "description": "markdown (default): HTML converted to Markdown, or the site's own Markdown when it offers one; text: plain text, no links or markup; html: the raw HTML (to see structure, selectors, meta tags)"},
@@ -11,7 +11,7 @@ export task tool(pattern, path, glob, regex)
11
11
  let r be grep(c.ws(path), pattern, opts)
12
12
  let lines be []
13
13
  each m in r["matches"]
14
- set lines to append(lines, c.unws(replace_text(m["file"], "\\", "/")) + ":" + text(m["line"]) + ": " + trim(m["text"]))
14
+ set lines to append(lines, c.unws(replace(m["file"], "\\", "/")) + ":" + text(m["line"]) + ": " + trim(m["text"]))
15
15
  when length(lines) == 0
16
16
  give "no matches"
17
17
  let out be join(lines, "\n")
@@ -33,10 +33,10 @@ task valid_name(name)
33
33
  export task slug()
34
34
  require env("LAMPSON_*")
35
35
  let ws be env("LAMPSON_WORKSPACE", "workspace")
36
- let norm be replace_text(lower(ws), "\\", "/")
36
+ let norm be replace(lower(ws), "\\", "/")
37
37
  let parts be where(split(norm, "/"), (x) => x != "")
38
38
  let base be when length(parts) > 0 then parts[length(parts) - 1] otherwise "workspace"
39
- let runs be find_all(base, "[a-z0-9]+")
39
+ let runs be regex_find_all(base, "[a-z0-9]+")
40
40
  let clean be when length(runs) == 0 then "project" otherwise join(runs, "-")
41
41
  let h be slice(decode(sha256(norm), "hex"), 0, 8)
42
42
  give clean + "-" + h
@@ -61,7 +61,7 @@ export task list()
61
61
  let title be ""
62
62
  each line in split(read_file(path(name)), "\n")
63
63
  when title == "" and trim(line) != ""
64
- set title to trim(replace_text(line, "#", ""))
64
+ set title to trim(replace(line, "#", ""))
65
65
  set out to append(out, {"name": name, "title": when length(title) > 120 then slice(title, 0, 120) otherwise title})
66
66
  recover err
67
67
  -- el runtime lanza "Not a directory: …" (mayúscula) cuando memory/<slug> aún no existe