lampson 0.2.6 → 0.2.8

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.
@@ -0,0 +1,573 @@
1
+ -- lib/tools/fetch.syn — traer una página web o una respuesta HTTP como Markdown/texto, sin ruido
2
+ --
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).
5
+ -- Esta tool combina lo mejor de los tres harnesses de referencia:
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.
8
+ -- * deepseek web-fetch-http — transporte: redirects MANUALES (http_get no los sigue) hasta 5 saltos,
9
+ -- revalidando cada salto con la política de hosts; tope de bytes; Content-Type clasificado (html /
10
+ -- texto / binario); cabecera "url · HTTP status · tipo · tamaño" y pie explícito al truncar.
11
+ -- * hermes web_tools.py — seguridad y UX: SSRF (metadata de la nube denegada siempre, hosts privados
12
+ -- piden aprobación — ver url.syn y permission.syn), secretos en la URL bloqueados, cabeza 75 % + cola
13
+ -- 25 % cortadas en límites de línea, texto COMPLETO en disco con la instrucción exacta para paginar
14
+ -- (read offset/limit), caché por URL con TTL, imágenes base64 fuera del contexto.
15
+ -- Conversión HTML→Markdown propia (sin DOM: regex del runtime, motor Rust sin lookaround): se quita
16
+ -- script/style/nav/aside/footer/svg/iframe, se preservan <pre> como bloques de código, headings, listas
17
+ -- (ol numeradas), tablas, links resueltos contra la URL final, énfasis, código inline; entidades decodificadas.
18
+ -- Además: si la página anuncia `<link rel="alternate" type="text/markdown" href=…>` se trae ESE recurso.
19
+ --
20
+ -- Tamaños: default 10k chars visibles (= SPILL_CAP: el modelo ve la página entera de una vez), tope 30k
21
+ -- (MAX_OUTPUT). loop.spill exime a fetch (como a read) porque trunca mejor por sí sola.
22
+ use "./common.syn" as c
23
+ use "./url.syn" as u
24
+
25
+ export let DEFAULT_CHARS be 10000
26
+ export let MIN_CHARS be 2000
27
+ export let MAX_CHARS be 30000
28
+ export let MAX_HOPS be 5
29
+ export let MAX_BODY be 2000000
30
+ export let DEFAULT_TTL be 1200
31
+ export let CACHE_DIR be ".lampson/spill"
32
+ let UA be "Mozilla/5.0 (compatible; lampson/agent; +https://lampson.org)"
33
+ let HEX be "0123456789abcdef"
34
+
35
+ -- ---------- helpers puros ----------
36
+
37
+ task accept_for(format)
38
+ when format == "html"
39
+ give "text/html, application/xhtml+xml;q=0.9, text/plain;q=0.5, */*;q=0.1"
40
+ when format == "text"
41
+ give "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
42
+ give "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
43
+
44
+ -- las claves de los headers llegan con mayúsculas variables (Location / location)
45
+ task header(h, name)
46
+ each k in keys(h)
47
+ when lower(k) == name
48
+ give text(h[k])
49
+ give ""
50
+
51
+ -- "text/html; charset=utf-8" → "text/html"
52
+ export task mime_of(content_type)
53
+ give trim(lower(split(text(content_type), ";")[0]))
54
+
55
+ -- html | text | binary
56
+ export task classify(mime)
57
+ when mime == "text/html" or mime == "application/xhtml+xml"
58
+ give "html"
59
+ when mime == ""
60
+ give "text"
61
+ when starts_with(mime, "text/")
62
+ give "text"
63
+ when mime == "application/json" or mime == "application/xml" or ends_with(mime, "+json") or ends_with(mime, "+xml") or mime == "application/javascript" or mime == "application/x-yaml" or mime == "application/yaml" or mime == "application/toml"
64
+ give "text"
65
+ give "binary"
66
+
67
+ export task fmt_chars(n)
68
+ when n < 1000
69
+ give text(floor(n)) + " chars"
70
+ let k be floor(n / 1000)
71
+ let d be floor((n - k * 1000) / 100)
72
+ give text(k) + "." + text(d) + "k chars"
73
+
74
+ task fmt_age(secs)
75
+ when secs < 60
76
+ give text(floor(secs)) + "s"
77
+ when secs < 3600
78
+ give text(floor(secs / 60)) + " min"
79
+ give text(floor(secs / 3600)) + " h"
80
+
81
+ task hex4(n)
82
+ let d3 be floor(n / 4096)
83
+ let r be n - d3 * 4096
84
+ let d2 be floor(r / 256)
85
+ set r to r - d2 * 256
86
+ let d1 be floor(r / 16)
87
+ let d0 be r - d1 * 16
88
+ give slice(HEX, d3, d3 + 1) + slice(HEX, d2, d2 + 1) + slice(HEX, d1, d1 + 1) + slice(HEX, d0, d0 + 1)
89
+
90
+ -- code point → texto (vía el parser JSON del runtime; no hay chr())
91
+ task cp_text(cp)
92
+ when cp < 32 and cp != 9 and cp != 10 and cp != 13
93
+ give ""
94
+ when cp > 1114111 or (cp >= 55296 and cp <= 57343)
95
+ give ""
96
+ let esc be ""
97
+ when cp < 65536
98
+ set esc to "\\u" + hex4(cp)
99
+ otherwise
100
+ let v be cp - 65536
101
+ let hi be 55296 + floor(v / 1024)
102
+ let lo be 56320 + (v - floor(v / 1024) * 1024)
103
+ set esc to "\\u" + hex4(hi) + "\\u" + hex4(lo)
104
+ let out be ""
105
+ try
106
+ set out to json_decode("\"" + esc + "\"")
107
+ recover e
108
+ set out to ""
109
+ give out
110
+
111
+ let HEX_VAL be {"0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "a": 10, "b": 11, "c": 12, "d": 13, "e": 14, "f": 15}
112
+
113
+ task hex_to_int(h)
114
+ let n be 0
115
+ let s be lower(h)
116
+ let i be 0
117
+ while i < length(s)
118
+ let ch be slice(s, i, i + 1)
119
+ when contains(HEX_VAL, ch)
120
+ set n to n * 16 + HEX_VAL[ch]
121
+ set i to i + 1
122
+ give n
123
+
124
+ let NAMED be {"nbsp": " ", "lt": "<", "gt": ">", "quot": "\"", "apos": "'", "copy": "©", "reg": "®", "trade": "™", "mdash": "—", "ndash": "–", "hellip": "…", "laquo": "«", "raquo": "»", "lsquo": "‘", "rsquo": "’", "ldquo": "“", "rdquo": "”", "middot": "·", "bull": "•", "rarr": "→", "larr": "←", "times": "×", "deg": "°", "euro": "€", "pound": "£", "yen": "¥", "cent": "¢", "sect": "§", "para": "¶", "shy": "", "zwnj": "", "zwj": "", "ensp": " ", "emsp": " ", "thinsp": " ", "eacute": "é", "egrave": "è", "aacute": "á", "iacute": "í", "oacute": "ó", "uacute": "ú", "ntilde": "ñ", "ccedil": "ç", "uuml": "ü", "ouml": "ö", "auml": "ä", "szlig": "ß", "agrave": "à", "ecirc": "ê", "acirc": "â", "ocirc": "ô", "iquest": "¿", "iexcl": "¡", "frac12": "½", "frac14": "¼", "plusmn": "±", "micro": "µ", "check": "✓", "hearts": "♥"}
125
+
126
+ export task decode_entities(s)
127
+ let out be s
128
+ -- numéricas: &#233; &#xE9;
129
+ each m in find_all(out, "&#[0-9]{1,7};|&#[xX][0-9A-Fa-f]{1,6};")
130
+ let inner be slice(m, 2, length(m) - 1)
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))
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};")
135
+ let name be slice(m, 1, length(m) - 1)
136
+ when contains(NAMED, name)
137
+ set out to replace_text(out, m, NAMED[name])
138
+ give replace_text(out, "&amp;", "&")
139
+
140
+ task strip_tags(s)
141
+ give replace_re(s, "(?s)<[^>]*>", "")
142
+
143
+ -- texto de un fragmento inline (título, texto de un link, celda): sin tags, entidades, espacios colapsados
144
+ task inline_text(s)
145
+ give trim(replace_re(decode_entities(strip_tags(s)), "\\s+", " "))
146
+
147
+ export task title_of(html)
148
+ -- capture con grupos devuelve una LISTA de grupos (verificado): [0] es el título
149
+ let t be capture(html, "(?is)<title[^>]*>(.*?)</title>")
150
+ when t == nothing
151
+ give ""
152
+ give inline_text(t[0])
153
+
154
+ -- ---------- HTML → Markdown / texto ----------
155
+
156
+ -- bloques <pre>: fuera antes de tocar espacios, de vuelta al final (marcadores ASCII improbables)
157
+ task pull_pre(h, plain)
158
+ let blocks be []
159
+ let out be h
160
+ each e in enumerate(find_all(h, "(?is)<pre\\b.*?</pre>"))
161
+ let blk be e["item"]
162
+ let langm be capture(blk, "(?i)language-([A-Za-z0-9_+#-]+)")
163
+ let lang be when langm == nothing then nothing otherwise langm[0]
164
+ let innerm be capture(blk, "(?is)<pre\\b[^>]*>(.*?)</pre>")
165
+ let inner be when innerm == nothing then "" otherwise innerm[0]
166
+ let code be decode_entities(strip_tags(inner))
167
+ set code to replace_text(code, "\r\n", "\n")
168
+ -- quitar un salto inicial/final (el HTML suele meterlos), no la indentación
169
+ when starts_with(code, "\n")
170
+ set code to slice(code, 1, length(code))
171
+ while ends_with(code, "\n")
172
+ set code to slice(code, 0, length(code) - 1)
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
+ set blocks to append(blocks, fenced)
175
+ set out to replace_text(out, blk, "%%LPRE" + text(e["index"]) + "%%")
176
+ give {"html": out, "blocks": blocks}
177
+
178
+ task push_pre(s, blocks)
179
+ let out be s
180
+ each e in enumerate(blocks)
181
+ set out to replace_text(out, "%%LPRE" + text(e["index"]) + "%%", e["item"])
182
+ give out
183
+
184
+ -- <ol> → "1. …" (find_all + reemplazo literal: replace_re no puede contar)
185
+ task number_lists(h)
186
+ let out be h
187
+ each blk in find_all(h, "(?is)<ol\\b[^>]*>.*?</ol>")
188
+ let parts be split(blk, "<li")
189
+ let items be []
190
+ each e in enumerate(parts)
191
+ when e["index"] > 0
192
+ -- "<li" + [atributos]> contenido </li>
193
+ let body be replace_re(e["item"], "(?s)^[^>]*>", "")
194
+ set body to replace_re(body, "(?is)</li>.*$", "")
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")
197
+ give out
198
+
199
+ -- <table> → tabla GFM (sin colspan; hermes/deepseek también los ignoran)
200
+ task tables(h)
201
+ let out be h
202
+ each blk in find_all(h, "(?is)<table\\b.*?</table>")
203
+ let rows be []
204
+ let first_is_head be false
205
+ each e in enumerate(split(blk, "<tr"))
206
+ when e["index"] > 0
207
+ let row be replace_re(e["item"], "(?s)^[^>]*>", "")
208
+ set row to replace_re(row, "(?is)</tr>.*$", "")
209
+ let cells be []
210
+ let row_td be replace_re(row, "(?i)<th\\b", "<td")
211
+ each ce in enumerate(split(row_td, "<td"))
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), "|", "\\|"))
216
+ when length(cells) > 0
217
+ when length(rows) == 0 and contains(lower(row), "<th")
218
+ set first_is_head to true
219
+ set rows to append(rows, "| " + join(cells, " | ") + " |")
220
+ when length(rows) > 0
221
+ let ncols be length(split(rows[0], " | "))
222
+ let seps be []
223
+ let i be 0
224
+ while i < ncols
225
+ set seps to append(seps, "---")
226
+ set i to i + 1
227
+ let sep be "| " + join(seps, " | ") + " |"
228
+ let with_sep be [rows[0], sep]
229
+ each e in enumerate(rows)
230
+ when e["index"] > 0
231
+ set with_sep to append(with_sep, e["item"])
232
+ set rows to with_sep
233
+ set out to replace_text(out, blk, "\n\n" + join(rows, "\n") + "\n\n")
234
+ give out
235
+
236
+ -- <a href> → [texto](url absoluta); en plain, solo el texto
237
+ task links(h, base, plain)
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>")
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])
244
+ let repl be txt
245
+ -- anclas internas (#x) sin destino real: "Skip to content", el "§"/"#" de cada heading → fuera
246
+ when abs == nothing
247
+ when starts_with(lower(txt), "skip to") or length(txt) <= 3
248
+ set repl to ""
249
+ when not plain and abs != nothing and txt != ""
250
+ set repl to when abs == txt then "<" + abs + ">" otherwise "[" + txt + "](" + abs + ")"
251
+ set out to replace_text(out, a, repl)
252
+ give out
253
+
254
+ -- blockquote: marcadores y luego prefijo "> " línea por línea
255
+ task quote_lines(s)
256
+ when not contains(s, "%%LBQ%%")
257
+ give s
258
+ let out be []
259
+ let inq be false
260
+ each l in split(s, "\n")
261
+ let line be l
262
+ let opens be length(split(line, "%%LBQ%%")) - 1
263
+ let closes be length(split(line, "%%/LBQ%%")) - 1
264
+ set line to replace_text(replace_text(line, "%%LBQ%%", ""), "%%/LBQ%%", "")
265
+ when inq or opens > 0
266
+ when trim(line) != ""
267
+ set line to "> " + trim(line)
268
+ set out to append(out, line)
269
+ when opens > closes
270
+ set inq to true
271
+ otherwise when closes > opens
272
+ set inq to false
273
+ give join(out, "\n")
274
+
275
+ 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)<!--.*?-->", "")
278
+ -- ruido: sin contenido para el modelo (un patrón por tag: el motor Rust no tiene backreferences)
279
+ -- (header también: en docs/GitHub es el menú del sitio; el título de la página va aparte en `title:`)
280
+ 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*>", "")
282
+ let pre be pull_pre(h, plain)
283
+ set h to pre["html"]
284
+ -- links primero (una celda o un ítem con <a> conserva el link); listas numeradas y tablas antes de
285
+ -- romper su estructura
286
+ set h to links(h, base, plain)
287
+ set h to number_lists(h)
288
+ set h to tables(h)
289
+ -- 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[^>]*>", "")
292
+ each lvl in ["1", "2", "3", "4", "5", "6"]
293
+ 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 ")
306
+ -- é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)>", "")
308
+ 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")
314
+ set h to strip_tags(h)
315
+ set h to decode_entities(h)
316
+ -- énfasis vacío que quedó de tags sin texto
317
+ set h to replace_re(h, "\\*\\*\\s*\\*\\*", "")
318
+ set h to replace_re(h, "``", "")
319
+ -- 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")
325
+ set h to quote_lines(h)
326
+ set h to push_pre(h, pre["blocks"])
327
+ set h to replace_re(h, "\\n{3,}", "\n\n")
328
+ give trim(h)
329
+
330
+ -- Markdown servido por el sitio: solo sacar imágenes base64 (hermes convert_base64_images_to_links)
331
+ 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"))
335
+
336
+ -- href del <link rel="alternate" type="text/markdown">, resuelto; nothing si no hay
337
+ export task markdown_alternate(html, base)
338
+ each l in find_all(text(html), "(?is)<link\\b[^>]*>")
339
+ let ll be lower(l)
340
+ 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*[\"']([^\"']*)[\"']")
342
+ when href != nothing
343
+ give u.resolve(base, href[0])
344
+ give nothing
345
+
346
+ -- ---------- recorte (hermes _truncate_with_footer): cabeza 75 % + cola 25 %, en límites de línea ----------
347
+ export task cut_output(body, max_chars, path)
348
+ when length(body) <= max_chars
349
+ give {"text": body, "truncated": false}
350
+ let head_budget be floor(max_chars * 0.75)
351
+ let tail_budget be max_chars - head_budget
352
+ let head be slice(body, 0, head_budget)
353
+ let tail be slice(body, length(body) - tail_budget, length(body))
354
+ let hl be split(head, "\n")
355
+ when length(hl) > 1 and length(hl[length(hl) - 1]) < head_budget / 2
356
+ set head to join(slice(hl, 0, length(hl) - 1), "\n")
357
+ let tl be split(tail, "\n")
358
+ when length(tl) > 1 and length(tl[0]) < tail_budget / 2
359
+ set tail to join(slice(tl, 1, length(tl)), "\n")
360
+ let next_line be length(split(head, "\n")) + 1
361
+ let foot be "\n\n──── [truncated] ────\nShowing " + fmt_chars(length(head)) + " (head) + " + fmt_chars(length(tail)) + " (tail) of " + fmt_chars(length(body)) + ". Full text saved to " + path + " — the omitted middle starts at line " + text(next_line) + ": read path=\"" + path + "\" offset=" + text(next_line) + " limit=200, or grep it. Do not fetch this URL again.\n─────────────────────"
362
+ give {"text": head + "\n\n[... middle omitted — see footer ...]\n\n" + tail + foot, "truncated": true}
363
+
364
+ -- ---------- transporte ----------
365
+
366
+ -- GET con redirects manuales: http_get devuelve el 3xx con Location sin seguirlo (verificado v0.6.14).
367
+ -- Cada salto pasa por la misma política de hosts que la URL original (deepseek revalida; hermes re-chequea
368
+ -- SSRF por hook): un 302 hacia 127.0.0.1 o la metadata de la nube NO se sigue.
369
+ task get_following(url, headers, t)
370
+ require net
371
+ let cur be url
372
+ let hops be 0
373
+ let done be false
374
+ let r be nothing
375
+ let origin0 be ""
376
+ while not done
377
+ let p be u.parse(cur)
378
+ when not p["ok"]
379
+ give {"ok": false, "status": 0, "error": "redirect to an invalid URL (" + p["error"] + ")", "url": cur, "hops": hops}
380
+ when hops == 0
381
+ set origin0 to p["origin"]
382
+ otherwise
383
+ -- un salto a un host público siempre vale; a uno privado solo dentro del MISMO origen que el
384
+ -- usuario ya aprobó (127.0.0.1:3000 → 127.0.0.1:8080 sería otro servicio); metadata jamás
385
+ let cls be u.host_class(p["host"])
386
+ when cls == "blocked" or (cls == "private" and p["origin"] != origin0)
387
+ give {"ok": false, "status": 0, "error": "redirected to a " + cls + " host (" + p["host"] + ") — not followed automatically; if that is intended, fetch " + p["url"] + " directly", "url": url, "hops": hops}
388
+ set cur to p["url"]
389
+ set r to nothing
390
+ try
391
+ set r to http_get(cur, headers, nothing, t)
392
+ recover e
393
+ give {"ok": false, "status": 0, "error": text(e), "url": cur, "hops": hops}
394
+ when not contains(r, "status")
395
+ give {"ok": false, "status": 0, "error": "no response", "url": cur, "hops": hops}
396
+ let st be r["status"]
397
+ when st == 0
398
+ give {"ok": false, "status": 0, "error": (when contains(r, "error") then text(r["error"]) otherwise "connection failed"), "url": cur, "hops": hops}
399
+ let loc be when contains(r, "headers") then header(r["headers"], "location") otherwise ""
400
+ when (st == 301 or st == 302 or st == 303 or st == 307 or st == 308) and loc != ""
401
+ set hops to hops + 1
402
+ when hops > MAX_HOPS
403
+ give {"ok": false, "status": st, "error": "more than " + text(MAX_HOPS) + " redirects (last: " + loc + ")", "url": cur, "hops": hops}
404
+ let nxt be u.resolve(cur, loc)
405
+ when nxt == nothing
406
+ give {"ok": false, "status": st, "error": "redirect to an unusable Location: " + loc, "url": cur, "hops": hops}
407
+ set cur to nxt
408
+ otherwise
409
+ set done to true
410
+ let hdrs be when contains(r, "headers") then r["headers"] otherwise {}
411
+ give {"ok": true, "status": r["status"], "headers": hdrs, "content_type": header(hdrs, "content-type"), "body": (when contains(r, "body") then text(r["body"]) otherwise ""), "url": cur, "hops": hops}
412
+
413
+ -- ---------- caché en disco (hermes extract cache): misma URL+formato dentro del TTL → sin red ----------
414
+ task cache_paths(url, format)
415
+ let p be u.parse(url)
416
+ let host be when p["ok"] then p["host"] otherwise "page"
417
+ let digest be slice(decode(sha256(text(url) + " " + format), "hex"), 0, 10)
418
+ let base be CACHE_DIR + "/fetch-" + u.slug(host) + "-" + digest
419
+ give {"body": base + (when format == "html" then ".html" otherwise (when format == "text" then ".txt" otherwise ".md")), "meta": base + ".json"}
420
+
421
+ task ttl()
422
+ require env("LAMPSON_*")
423
+ let e be env("LAMPSON_FETCH_TTL", "")
424
+ when e == ""
425
+ give DEFAULT_TTL
426
+ give floor(number(e))
427
+
428
+ task read_cache(paths)
429
+ require file.read(".lampson")
430
+ require file.read(".lampson/*")
431
+ require time
432
+ let meta be nothing
433
+ try
434
+ set meta to json_decode(read_file(paths["meta"]))
435
+ recover e
436
+ give nothing
437
+ when meta == nothing
438
+ give nothing
439
+ when not contains(meta, "fetched_at")
440
+ give nothing
441
+ let age be now() - meta["fetched_at"]
442
+ when age > ttl() or age < 0
443
+ give nothing
444
+ let body be nothing
445
+ try
446
+ set body to read_file(paths["body"])
447
+ recover e
448
+ give nothing
449
+ set meta["body"] to body
450
+ set meta["age"] to age
451
+ give meta
452
+
453
+ task write_cache(paths, meta, body)
454
+ require file(".lampson")
455
+ require file(".lampson/*")
456
+ try
457
+ write_file(paths["body"], body)
458
+ write_file(paths["meta"], json_encode(meta))
459
+ recover e
460
+ give false
461
+ give true
462
+
463
+ -- ---------- la tool ----------
464
+
465
+ task clamp(v, dflt, lo, hi)
466
+ when v == nothing
467
+ give dflt
468
+ let n be floor(number(text(v)))
469
+ when n < lo
470
+ give lo
471
+ when n > hi
472
+ give hi
473
+ give n
474
+
475
+ export task tool(url, format, max_chars, timeout)
476
+ require net
477
+ require time
478
+ require env("LAMPSON_*")
479
+ require file(".lampson")
480
+ require file(".lampson/*")
481
+ let fmt be when format == nothing then "markdown" otherwise lower(trim(text(format)))
482
+ when fmt != "markdown" and fmt != "text" and fmt != "html"
483
+ set fmt to "markdown"
484
+ let budget be clamp(max_chars, DEFAULT_CHARS, MIN_CHARS, MAX_CHARS)
485
+ let t be clamp(timeout, 30, 1, 90)
486
+ let plain be fmt == "text"
487
+ -- validación (defensa en profundidad: permission.syn ya deniega estas antes de llegar aquí)
488
+ let why be u.sensitive(url)
489
+ when why != nothing
490
+ give "ERROR: refused — the URL carries " + why + ". Secrets never travel in URLs (they end up in logs and third-party servers). Use bash with curl and a header if an authenticated request is really needed."
491
+ let p be u.parse(url)
492
+ when not p["ok"]
493
+ give "ERROR: " + p["error"]
494
+ when u.host_class(p["host"]) == "blocked"
495
+ give "ERROR: refused — " + p["host"] + " is a cloud metadata endpoint (instance credentials); never a legitimate target."
496
+ let paths be cache_paths(p["url"], fmt)
497
+ -- caché
498
+ let cached be read_cache(paths)
499
+ when cached != nothing
500
+ let cut be cut_output(cached["body"], budget, paths["body"])
501
+ give text(cached["header"]) + " · cached " + fmt_age(cached["age"]) + " ago (LAMPSON_FETCH_TTL=" + text(ttl()) + "s)" + (when text(cached["title"]) != "" then "\ntitle: " + text(cached["title"]) otherwise "") + "\n\n" + cut["text"]
502
+ -- red
503
+ let headers be {"User-Agent": UA, "Accept": accept_for(fmt), "Accept-Language": "en, es;q=0.9, *;q=0.5"}
504
+ let r be get_following(p["url"], headers, t)
505
+ when not r["ok"]
506
+ give "ERROR: fetch " + text(r["url"]) + ": " + text(r["error"]) + (when r["hops"] > 0 then " (after " + text(r["hops"]) + " redirect" + (when r["hops"] == 1 then "" otherwise "s") + ")" otherwise "")
507
+ let mime be mime_of(r["content_type"])
508
+ let kind be classify(mime)
509
+ let body be r["body"]
510
+ let source_cut be false
511
+ when length(body) > MAX_BODY
512
+ set body to slice(body, 0, MAX_BODY)
513
+ set source_cut to true
514
+ let status be r["status"]
515
+ let head be text(r["url"]) + " · HTTP " + text(status) + " · " + (when mime == "" then "no content-type" otherwise mime)
516
+ -- GitHub renderiza el README dentro de mucho cromo: el archivo crudo es 10× más barato
517
+ let hint be ""
518
+ when p["host"] == "github.com" or p["host"] == "www.github.com"
519
+ let segs be split(p["path"], "/")
520
+ when length(segs) >= 3 and status == 200
521
+ when length(segs) == 3 or (length(segs) == 4 and segs[3] == "")
522
+ set hint to "\ntip: the README and any file are cheaper raw: https://raw.githubusercontent.com/" + segs[1] + "/" + segs[2] + "/HEAD/README.md (or /HEAD/<path>)"
523
+ otherwise when length(segs) >= 6 and segs[3] == "blob"
524
+ set hint to "\ntip: raw file: https://raw.githubusercontent.com/" + segs[1] + "/" + segs[2] + "/" + join(slice(segs, 4, length(segs)), "/")
525
+ when kind == "binary"
526
+ give head + " · binary, " + fmt_chars(length(body)) + " — fetch cannot render " + mime + ". For a download use bash: curl -sSL -o <file> <url> (then a converter for PDFs); for an image ask the user to paste it."
527
+ let title be ""
528
+ let out be ""
529
+ let note be ""
530
+ when kind == "html" and fmt != "html"
531
+ set title to title_of(body)
532
+ -- el sitio ofrece Markdown en otra URL → esa vale más que cualquier conversión
533
+ let alt be markdown_alternate(body, r["url"])
534
+ let got_alt be false
535
+ when alt != nothing
536
+ let ap be u.parse(alt)
537
+ -- (`and` no cortocircuita: indexar un map sin la clave explota → whens anidados)
538
+ when ap["ok"]
539
+ when ap["origin"] == p["origin"]
540
+ let ar be get_following(alt, {"User-Agent": UA, "Accept": "text/markdown, text/plain;q=0.9, */*;q=0.1"}, t)
541
+ when ar["ok"]
542
+ when ar["status"] == 200 and classify(mime_of(ar["content_type"])) == "text"
543
+ set out to clean_markdown(ar["body"])
544
+ set note to " → markdown alternate " + text(ar["url"])
545
+ set got_alt to true
546
+ when not got_alt
547
+ set out to to_markdown(body, r["url"], plain)
548
+ set note to when plain then " → text" otherwise " → markdown"
549
+ otherwise when kind == "html"
550
+ set out to trim(body)
551
+ otherwise
552
+ -- texto/markdown/json servido tal cual (solo sin imágenes base64)
553
+ set out to clean_markdown(body)
554
+ when out == ""
555
+ set out to "(empty body)"
556
+ -- errores HTTP: el cuerpo suele explicar (404 de docs, 403 de Cloudflare) pero no vale 10k chars
557
+ let limit be when status >= 400 then MIN_CHARS otherwise budget
558
+ set head to head + note + " · " + fmt_chars(length(out)) + (when r["hops"] > 0 then " · " + text(r["hops"]) + " redirect" + (when r["hops"] == 1 then "" otherwise "s") otherwise "") + (when source_cut then " · source cut at 2 MB" otherwise "")
559
+ -- a disco siempre: paginar con read/grep y caché
560
+ write_cache(paths, {"url": p["url"], "final_url": r["url"], "status": status, "mime": mime, "title": title, "format": fmt, "header": head, "fetched_at": now(), "chars": length(out)}, out)
561
+ let cut be cut_output(out, limit, paths["body"])
562
+ give head + (when title != "" then "\ntitle: " + title otherwise "") + hint + "\n\n" + cut["text"]
563
+
564
+ export let SPEC be {
565
+ "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.",
567
+ "parameters": {"type": "object", "properties": {
568
+ "url": {"type": "string", "description": "Absolute http(s) URL"},
569
+ "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)"},
570
+ "max_chars": {"type": "integer", "description": "Characters returned inline (default 10000, 2000-30000). Longer pages are head+tail truncated and saved whole to a file named in the footer."},
571
+ "timeout": {"type": "integer", "description": "Seconds (default 30, max 90)"}
572
+ }, "required": ["url"]}
573
+ }
@@ -7,9 +7,22 @@
7
7
  -- El system prompt lleva SOLO el índice (nombre + primera línea de cada nota); el contenido entra al
8
8
  -- contexto cuando el agente llama `memory(read)`. Son archivos: el humano los lee/edita a mano y la
9
9
  -- UI web los muestra.
10
+ --
11
+ -- Cómo llega el proceso hasta ahí: el cwd del workspace es .lampson/ws/<slug>/, y `memory` es un link
12
+ -- (junction/symlink) a <home>/memory. La capability file("memory/*") es léxica sobre ese link, así que ESTA
13
+ -- tool es la única puerta: read/ls/grep/bash están confinadas a workspace/ y no ven la carpeta. Si el link
14
+ -- está roto (destino inexistente), write_file falla con "No such file or directory": `available()` lo
15
+ -- detecta, el prompt lo dice, y los errores de escritura explican qué hacer (correr `lampson` de nuevo
16
+ -- repara los links — workspaces.ensure_link) en vez de dejar que el modelo improvise notas con bash.
10
17
  use "./common.syn" as c
11
18
 
12
19
  export let ROOT be "memory"
20
+ let HOW_TO_FIX be "The memory folder is a link to Lampson's install (outside the project) and it is not reachable from this workspace — probably a broken link. Ask the user to run `lampson` again in this project (it repairs the links). Do NOT write notes with bash or inside the repo instead."
21
+
22
+ -- ¿se puede llegar a la carpeta de memoria? (false = link roto o inexistente)
23
+ export task available()
24
+ require file.read("memory")
25
+ give file_exists(ROOT)
13
26
 
14
27
  task valid_name(name)
15
28
  when name == nothing or name == ""
@@ -75,9 +88,16 @@ export task note_write(name, content)
75
88
  require file("memory/*")
76
89
  when not valid_name(name)
77
90
  raise("invalid note name '" + text(name) + "' (letters, digits, - or _)")
78
- write_file(path(name), content)
91
+ save(name, content)
79
92
  give "saved memory/" + slug() + "/" + name + ".md (" + text(length(content)) + " chars)"
80
93
 
94
+ -- escribe una nota; un fallo del sistema de archivos se explica (link roto) en vez de salir crudo
95
+ task save(name, content)
96
+ try
97
+ write_file(path(name), content)
98
+ recover err
99
+ raise("cannot write " + path(name) + ": " + text(err) + ". " + HOW_TO_FIX)
100
+
81
101
  export task note_append(name, content)
82
102
  require env("LAMPSON_*")
83
103
  require file("memory")
@@ -90,7 +110,7 @@ export task note_append(name, content)
90
110
  recover err
91
111
  set prev to ""
92
112
  let joined be when prev == "" then content otherwise prev + "\n\n" + content
93
- write_file(path(name), joined)
113
+ save(name, joined)
94
114
  give "appended to " + name + ".md (" + text(length(joined)) + " chars total)"
95
115
 
96
116
  export task note_clear(name)
@@ -99,7 +119,7 @@ export task note_clear(name)
99
119
  require file("memory/*")
100
120
  when not valid_name(name)
101
121
  raise("invalid note name")
102
- write_file(path(name), "")
122
+ save(name, "")
103
123
  give "cleared " + name + ".md (the file stays empty; the user can delete it)"
104
124
 
105
125
  -- sección del system prompt
@@ -107,8 +127,10 @@ export task prompt_section()
107
127
  require env("LAMPSON_*")
108
128
  require file.read("memory")
109
129
  require file.read("memory/*")
130
+ let head be "\n\n# Project memory (your own notes about THIS project, in memory/" + slug() + "/ — a folder OUTSIDE the project, reachable ONLY through the memory tool: read/ls/grep/bash are confined to the workspace and will not find it. Read with memory(read), keep notes current with memory(write|append).)"
131
+ when not available()
132
+ give head + "\n(UNAVAILABLE right now: the memory folder is not reachable from this workspace — a broken link in Lampson's install. Do not look for it with ls/bash and do not write notes inside the repo; tell the user to run `lampson` again in this project to repair it.)"
110
133
  let items be list()
111
- let head be "\n\n# Project memory (your own notes about THIS project, in memory/" + slug() + "/ — read with memory(read), keep them current with memory(write|append))"
112
134
  when length(items) == 0
113
135
  give head + "\n(empty — when you discover something non-obvious about this project: how to run/test it, gotchas, decisions, where things live — save it with memory(write). Keep notes short and factual.)"
114
136
  let lines be [head]
@@ -139,7 +161,7 @@ export task tool(action, name, content)
139
161
 
140
162
  export let SPEC be {
141
163
  "name": "memory",
142
- "description": "Your persistent notes about THIS project, kept across sessions (Markdown files in Lampson's memory folder, never inside the repo). Use `write` to save non-obvious facts you discovered and will need again: how to run/build/test, environment quirks, architecture decisions, where things live, bugs and their causes, what the user prefers. `append` adds to an existing note, `read` loads one, `list` shows them, `delete` clears one. The system prompt shows the index of notes — read the relevant ones before repeating an investigation. Keep notes short, factual and current: update a note instead of writing a contradictory one.",
164
+ "description": "Your persistent notes about THIS project, kept across sessions (Markdown files in Lampson's memory folder: OUTSIDE the project and outside the reach of your other tools — read/ls/grep/bash are confined to the workspace, so this tool is the ONLY way to those notes; never keep notes with bash or inside the repo). Use `write` to save non-obvious facts you discovered and will need again: how to run/build/test, environment quirks, architecture decisions, where things live, bugs and their causes, what the user prefers. `append` adds to an existing note, `read` loads one, `list` shows them, `delete` clears one. The system prompt shows the index of notes — read the relevant ones before repeating an investigation. Keep notes short, factual and current: update a note instead of writing a contradictory one.",
143
165
  "parameters": {"type": "object", "properties": {
144
166
  "action": {"type": "string", "enum": ["list", "read", "write", "append", "delete"]},
145
167
  "name": {"type": "string", "description": "Note id: letters, digits, - or _ (e.g. how-to-run, db-schema, gotchas)"},