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.
Files changed (79) hide show
  1. package/.env.example +10 -1
  2. package/README.md +9 -3
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +112 -8
  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 +30 -9
  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 +242 -0
  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 +41 -8
  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 +47 -2
  25. package/lib/prompt.syn +10 -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 +600 -0
  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 +220 -0
  39. package/lib/tools.syn +25 -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 +12 -3
  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/sidebar.css +21 -0
  52. package/public/css/tokens.css +1 -1
  53. package/public/hub.html +11 -1
  54. package/public/icon-192.png +0 -0
  55. package/public/icon-512.png +0 -0
  56. package/public/icon-maskable-512.png +0 -0
  57. package/public/icons-src/badge.svg +12 -0
  58. package/public/icons-src/icon-maskable.svg +15 -0
  59. package/public/icons-src/icon.svg +13 -0
  60. package/public/index.html +14 -3
  61. package/public/js/agents.js +1 -1
  62. package/public/js/chat.js +1 -1
  63. package/public/js/config.js +145 -2
  64. package/public/js/core.js +5 -3
  65. package/public/js/devices.js +80 -0
  66. package/public/js/editor.js +232 -0
  67. package/public/js/login.js +78 -0
  68. package/public/js/procs.js +1 -1
  69. package/public/js/pwa.js +5 -0
  70. package/public/js/tree.js +224 -14
  71. package/public/login.html +43 -0
  72. package/public/manifest.webmanifest +16 -0
  73. package/public/offline.html +24 -0
  74. package/public/sw.js +63 -0
  75. package/public/vendor/highlight.min.js +1343 -0
  76. package/public/vendor/qrcode.js +2297 -0
  77. package/skills/computer-use/SKILL.md +178 -0
  78. package/skills/lampson/SKILL.md +13 -0
  79. package/web.syn +82 -7
@@ -38,7 +38,7 @@ task path(name, ext)
38
38
  give DIR + "/" + name + "." + ext
39
39
 
40
40
  task tail_lines(content, n)
41
- let lines be split(replace_text(content, "\r", ""), "\n")
41
+ let lines be split(replace(content, "\r", ""), "\n")
42
42
  when length(lines) > n
43
43
  set lines to slice(lines, length(lines) - n, length(lines))
44
44
  give join(lines, "\n")
@@ -211,7 +211,7 @@ export task resume_note(sid)
211
211
  give "[harness] This session was resumed after a lampson restart. Managed processes do NOT survive a restart — anything this conversation started earlier is gone unless listed as running below. Current real state:\n" + join(lines, "\n") + "\nOld logs remain in " + DIR + "/<name>.log. Start a process again with the `process` tool only if it is needed now; do not assume any server is up."
212
212
 
213
213
  task first_line_of(s)
214
- let t be replace_text(text(s), "\r", "")
214
+ let t be replace(text(s), "\r", "")
215
215
  give split(t, "\n")[0]
216
216
 
217
217
  -- {run, session} actuales (blackboard "lampson:run" / "lampson:session"; vacíos fuera de un entry)
@@ -246,43 +246,61 @@ export task list()
246
246
  give []
247
247
  give out
248
248
 
249
- -- Puertos TCP en escucha (cualquier proceso, gestionado o no): [{port, pid, name, command}] — via proc.sh ports
250
- -- (Windows: netstat+tasklist · Linux: ss · macOS: lsof). Un servidor huérfano no aparece en list(); acá sí.
251
- export task listeners()
249
+ -- Todos los puertos TCP en escucha con su dueño, en crudo: [{port, pid, name}] — UNA llamada a proc.sh ports
250
+ -- (Windows: netstat+tasklist · Linux: ss · macOS: lsof), sin filtrar y sin la línea de comando. Para encontrar y
251
+ -- parar los procesos de Lampson por su puerto (lib/workspaces.syn): el edge de --domain escucha en :443.
252
+ export task port_owners()
252
253
  require exec
253
254
  require env("LAMPSON_*")
254
255
  require env("OS")
255
- require file(".lampson")
256
- require file(".lampson/*")
257
256
  let out be []
258
257
  try
259
258
  let r be run(shell(), ["lib/tools/proc.sh", "ports"], 20, {"cwd": "."})
260
- let seen be {}
261
- each line in split(replace_text(r["stdout"], "\r", ""), "\n")
259
+ each line in split(replace(r["stdout"], "\r", ""), "\n")
262
260
  let parts be where(split(trim(line), " "), (x) => x != "")
263
- when length(parts) >= 2
264
- let port be floor(number(parts[0]))
265
- let nm be when length(parts) >= 3 then parts[2] otherwise "?"
266
- when port >= 1024 and port < 49152 and not contains(SYSTEM_PROCS, lower(nm)) and not contains(seen, text(port))
267
- set seen[text(port)] to true
268
- set out to append(out, {"port": port, "pid": floor(number(parts[1])), "name": nm, "command": ""})
261
+ when length(parts) >= 2 and matches(parts[0], "[0-9]+") and matches(parts[1], "[0-9]+")
262
+ set out to append(out, {"port": floor(number(parts[0])), "pid": floor(number(parts[1])), "name": when length(parts) >= 3 then parts[2] otherwise "?"})
269
263
  recover err
270
264
  give out
265
+ give out
266
+
267
+ -- Puertos TCP en escucha que le interesan al usuario y al agente (panel «Puertos», tool process): sin los del
268
+ -- sistema ni los efímeros, uno por puerto, con la línea de comando: [{port, pid, name, command}]. Un servidor
269
+ -- huérfano no aparece en list(); acá sí.
270
+ export task listeners()
271
+ require exec
272
+ require env("LAMPSON_*")
273
+ require env("OS")
274
+ require file(".lampson")
275
+ require file(".lampson/*")
276
+ let out be []
277
+ let seen be {}
278
+ each l in port_owners()
279
+ when l["port"] >= 1024 and l["port"] < 49152 and not contains(SYSTEM_PROCS, lower(l["name"])) and not contains(seen, text(l["port"]))
280
+ set seen[text(l["port"])] to true
281
+ set out to append(out, {"port": l["port"], "pid": l["pid"], "name": l["name"], "command": ""})
271
282
  -- la línea de comando dice QUÉ es (next dev, un MCP server de otra herramienta, synsema serve…); un run por pid
272
283
  let cmds be {}
273
284
  let enriched be []
274
285
  each l in out
275
286
  let key be text(l["pid"])
276
287
  when not contains(cmds, key)
277
- try
278
- let r be run(shell(), ["lib/tools/proc.sh", "info", key], 15, {"cwd": "."})
279
- set cmds[key] to trim(replace_text(r["stdout"], "\n", " "))
280
- recover err
281
- set cmds[key] to ""
288
+ set cmds[key] to command_of(l["pid"])
282
289
  set l["command"] to when length(cmds[key]) > 300 then slice(cmds[key], 0, 300) + "…" otherwise cmds[key]
283
290
  set enriched to append(enriched, l)
284
291
  give sort_by(enriched, (x) => x["port"])
285
292
 
293
+ -- la línea de comando de un pid ("" si no se puede saber) — via proc.sh info
294
+ export task command_of(pid)
295
+ require exec
296
+ require env("LAMPSON_*")
297
+ require env("OS")
298
+ try
299
+ let r be run(shell(), ["lib/tools/proc.sh", "info", text(floor(pid))], 15, {"cwd": "."})
300
+ give trim(replace(r["stdout"], "\n", " "))
301
+ recover err
302
+ give ""
303
+
286
304
  -- matar un pid ajeno (huérfano de otra herramienta): taskkill /T o kill_tree, via proc.sh
287
305
  export task kill_pid(pid)
288
306
  require exec
@@ -0,0 +1,26 @@
1
+ // qr.js <texto> — dibuja un QR en la terminal (lampson --pair): medio bloque por fila de módulos (▀ ▄ █), con
2
+ // colores FORZADOS (negro sobre blanco) para que la cámara lo lea igual con tema claro u oscuro. Usa la misma
3
+ // librería que el QR de la web (public/vendor/qrcode.js).
4
+ const path = require('path');
5
+ const qrcode = require(path.join(__dirname, '..', '..', 'public', 'vendor', 'qrcode.js'));
6
+
7
+ const text = process.argv[2];
8
+ if (!text) { console.error('uso: node qr.js <texto>'); process.exit(2); }
9
+ const qr = qrcode(0, 'M');
10
+ qr.addData(text);
11
+ qr.make();
12
+
13
+ const n = qr.getModuleCount();
14
+ const quiet = 3; // margen claro alrededor (la cámara lo necesita para encontrar el código)
15
+ const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && qr.isDark(r, c);
16
+ const ON = '\x1b[30;107m', OFF = '\x1b[0m'; // texto negro, fondo blanco brillante
17
+ const lines = [];
18
+ for (let r = -quiet; r < n + quiet; r += 2) {
19
+ let line = ' ' + ON;
20
+ for (let c = -quiet; c < n + quiet; c++) {
21
+ const top = dark(r, c), bottom = dark(r + 1, c);
22
+ line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
23
+ }
24
+ lines.push(line + OFF);
25
+ }
26
+ process.stdout.write(lines.join('\n') + '\n');
@@ -0,0 +1,220 @@
1
+ -- lib/tools/url.syn — URLs para la tool fetch y para permission.syn (puro, sin capacidades)
2
+ --
3
+ -- parse(url) → {ok, error?, url, scheme, host, port, path, query, origin, dir}
4
+ -- host_class(host) → "public" | "private" | "blocked"
5
+ -- sensitive(url) → motivo (texto) si la URL lleva una credencial, o nothing
6
+ -- resolve(base, href) → URL absoluta de un href (relativo o no), o nothing si no es navegable (#, javascript:)
7
+ --
8
+ -- Política de hosts (tomada de hermes url_safety.py, sin resolver DNS: el runtime no expone un resolver):
9
+ -- * "blocked": endpoints de metadata de la nube (169.254.169.254 y familia, metadata.google.internal).
10
+ -- Nunca son un destino legítimo: se deniegan SIEMPRE, incluso en yolo.
11
+ -- * "private": loopback, redes privadas (10/8, 172.16/12, 192.168/16, CGNAT 100.64/10), link-local,
12
+ -- localhost y sufijos internos. Legítimo para mirar el propio dev server → PIDE aprobación (yolo permite,
13
+ -- strict deniega). Un host numérico ambiguo (entero, hex, octal) cae aquí también.
14
+ -- * "public": el resto. Límite conocido: sin DNS no se detecta un nombre público que resuelve a una IP
15
+ -- privada (DNS rebinding); hermes lo cubre con un resolver, aquí queda documentado.
16
+
17
+ let SENSITIVE_PARAMS be ["access_token", "api_key", "apikey", "auth_token", "authorization", "awsaccesskeyid", "client_secret", "credential", "credentials", "jwt", "password", "passwd", "secret", "session_id", "signature", "token", "private_key", "x_amz_security_token", "x_amz_signature", "x-amz-security-token", "x-amz-signature"]
18
+ let SECRET_RE be "(?i)(\\bsk-(ant-|proj-)?[a-z0-9_-]{24,}|\\bghp_[a-z0-9]{20,}|\\bgithub_pat_[a-z0-9_]{20,}|\\bgho_[a-z0-9]{20,}|\\bglpat-[a-z0-9_-]{16,}|\\bxox[abprs]-[a-z0-9-]{10,}|\\bakia[0-9a-z]{16}\\b|\\baiza[0-9a-z_-]{30,}|-----begin [a-z ]*private key|\\beyj[a-z0-9_-]{20,}\\.eyj[a-z0-9_-]{20,})"
19
+
20
+ task fail(msg)
21
+ give {"ok": false, "error": msg}
22
+
23
+ export task parse(raw)
24
+ when raw == nothing
25
+ give fail("missing URL")
26
+ let u be trim(text(raw))
27
+ -- "https:// host" — el modelo a veces mete un espacio tras el esquema (hermes normalize_url_for_request)
28
+ set u to regex_replace(u, "^([A-Za-z][A-Za-z0-9+.-]*://)\\s+", "\\1")
29
+ when u == ""
30
+ give fail("missing URL")
31
+ when length(u) > 2048
32
+ give fail("URL longer than 2048 chars")
33
+ when contains(u, " ") or contains(u, "\n") or contains(u, "\t")
34
+ give fail("URL contains whitespace: " + u)
35
+ let sp be split(u, "://")
36
+ when length(sp) < 2
37
+ give fail("not an absolute http(s) URL: " + u)
38
+ let scheme be lower(sp[0])
39
+ when scheme != "http" and scheme != "https"
40
+ give fail("unsupported scheme '" + scheme + "' (only http and https)")
41
+ let rest be join(slice(sp, 1, length(sp)), "://")
42
+ -- fragmento y query
43
+ let frag_sp be split(rest, "#")
44
+ set rest to frag_sp[0]
45
+ let q_sp be split(rest, "?")
46
+ let query be when length(q_sp) > 1 then join(slice(q_sp, 1, length(q_sp)), "?") otherwise ""
47
+ set rest to q_sp[0]
48
+ -- autoridad y path
49
+ let slash_sp be split(rest, "/")
50
+ let authority be slash_sp[0]
51
+ let path be when length(slash_sp) > 1 then "/" + join(slice(slash_sp, 1, length(slash_sp)), "/") otherwise "/"
52
+ when contains(authority, "@")
53
+ give fail("credentials in the URL are not allowed")
54
+ when authority == ""
55
+ give fail("missing host: " + u)
56
+ let host be lower(authority)
57
+ let port be ""
58
+ when starts_with(host, "[")
59
+ -- IPv6 literal: [::1]:8080
60
+ let close be split(host, "]")
61
+ set host to slice(close[0], 1, length(close[0]))
62
+ when length(close) > 1 and starts_with(close[1], ":")
63
+ set port to slice(close[1], 1, length(close[1]))
64
+ otherwise
65
+ let hp be split(host, ":")
66
+ when length(hp) > 2
67
+ give fail("malformed host: " + authority)
68
+ set host to hp[0]
69
+ when length(hp) == 2
70
+ set port to hp[1]
71
+ when port != "" and not matches(port, "[0-9]{1,5}")
72
+ give fail("malformed port: " + port)
73
+ -- "example.com." (FQDN con punto final) ≡ "example.com"
74
+ while ends_with(host, ".")
75
+ set host to slice(host, 0, length(host) - 1)
76
+ when host == ""
77
+ give fail("missing host: " + u)
78
+ let origin be scheme + "://" + host + (when port != "" then ":" + port otherwise "")
79
+ let dir_parts be split(path, "/")
80
+ let dir be join(slice(dir_parts, 0, length(dir_parts) - 1), "/") + "/"
81
+ give {"ok": true, "url": origin + path + (when query != "" then "?" + query otherwise ""), "scheme": scheme, "host": host, "port": port, "path": path, "query": query, "origin": origin, "dir": dir}
82
+
83
+ -- octetos de una IPv4 en notación decimal con puntos, o nothing
84
+ task ipv4_octets(host)
85
+ when not matches(host, "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}")
86
+ give nothing
87
+ let out be []
88
+ each p in split(host, ".")
89
+ let n be floor(number(p))
90
+ when n > 255
91
+ give nothing
92
+ set out to append(out, n)
93
+ give out
94
+
95
+ task ipv4_class(o)
96
+ let a be o[0]
97
+ let b be o[1]
98
+ when a == 169 and b == 254
99
+ give "blocked"
100
+ when a == 100 and b == 100 and o[2] == 100 and o[3] == 200
101
+ give "blocked"
102
+ when a == 127 or a == 10 or a == 0
103
+ give "private"
104
+ when a == 192 and b == 168
105
+ give "private"
106
+ when a == 172 and b >= 16 and b <= 31
107
+ give "private"
108
+ when a == 100 and b >= 64 and b <= 127
109
+ give "private"
110
+ when a >= 224
111
+ give "private"
112
+ give "public"
113
+
114
+ export task host_class(host)
115
+ let h be lower(text(host))
116
+ when h == "metadata.google.internal" or h == "metadata.goog" or h == "fd00:ec2::254"
117
+ give "blocked"
118
+ let o be ipv4_octets(h)
119
+ when o != nothing
120
+ give ipv4_class(o)
121
+ -- IPv6: loopback, unspecified, ULA fc00::/7, link-local fe80::/10, IPv4 mapeadas ::ffff:a.b.c.d
122
+ when contains(h, ":")
123
+ when h == "::1" or h == "::"
124
+ give "private"
125
+ when starts_with(h, "::ffff:")
126
+ let inner be ipv4_octets(slice(h, 7, length(h)))
127
+ give when inner != nothing then ipv4_class(inner) otherwise "private"
128
+ when starts_with(h, "fc") or starts_with(h, "fd") or starts_with(h, "fe8") or starts_with(h, "fe9") or starts_with(h, "fea") or starts_with(h, "feb")
129
+ give "private"
130
+ give "public"
131
+ when h == "localhost" or ends_with(h, ".localhost") or ends_with(h, ".internal") or ends_with(h, ".local") or ends_with(h, ".home.arpa") or ends_with(h, ".lan")
132
+ give "private"
133
+ -- formas numéricas ambiguas (2130706433, 0x7f000001, 0177.0.0.1): clásicos de bypass → tratar como privado
134
+ when matches(h, "[0-9]+") or matches(h, "0x[0-9a-f]+") or matches(h, "[0-9x.]+")
135
+ give "private"
136
+ give "public"
137
+
138
+ -- %XX → carácter (para inspeccionar una URL con secretos codificados)
139
+ task percent_decode(s)
140
+ let out be s
141
+ each m in regex_find_all(s, "%[0-9A-Fa-f]{2}")
142
+ let ch be nothing
143
+ try
144
+ set ch to json_decode("\"\\u00" + slice(m, 1, 3) + "\"")
145
+ recover e
146
+ set ch to nothing
147
+ when ch != nothing
148
+ set out to replace(out, m, ch)
149
+ give out
150
+
151
+ export task sensitive(raw)
152
+ when raw == nothing
153
+ give nothing
154
+ let u be text(raw)
155
+ let decoded be percent_decode(u)
156
+ when length(regex_find_all(u, SECRET_RE)) > 0 or length(regex_find_all(decoded, SECRET_RE)) > 0
157
+ give "an API key or token"
158
+ let p be parse(u)
159
+ when not p["ok"]
160
+ give nothing
161
+ when p["query"] == ""
162
+ give nothing
163
+ each pair in split(p["query"], "&")
164
+ let kv be split(pair, "=")
165
+ let k be lower(trim(percent_decode(kv[0])))
166
+ let v be when length(kv) > 1 then join(slice(kv, 1, length(kv)), "=") otherwise ""
167
+ when v != "" and contains(SENSITIVE_PARAMS, k)
168
+ give "a credential-like query parameter (" + kv[0] + ")"
169
+ give nothing
170
+
171
+ -- "/a/b/../c/./d" → "/a/c/d"
172
+ task normalize_path(path)
173
+ let out be []
174
+ each seg in split(path, "/")
175
+ when seg == ".."
176
+ when length(out) > 0
177
+ set out to slice(out, 0, length(out) - 1)
178
+ otherwise when seg != "." and seg != ""
179
+ set out to append(out, seg)
180
+ give "/" + join(out, "/") + (when ends_with(path, "/") and length(out) > 0 then "/" otherwise "")
181
+
182
+ export task resolve(base, href)
183
+ when href == nothing
184
+ give nothing
185
+ let h be trim(text(href))
186
+ let lh be lower(h)
187
+ when h == "" or starts_with(h, "#") or starts_with(lh, "javascript:") or starts_with(lh, "data:") or starts_with(lh, "mailto:") or starts_with(lh, "tel:")
188
+ give nothing
189
+ when matches(h, "[A-Za-z][A-Za-z0-9+.-]*:.*")
190
+ give h
191
+ let b be parse(base)
192
+ when not b["ok"]
193
+ give h
194
+ when starts_with(h, "//")
195
+ give b["scheme"] + ":" + h
196
+ -- separar query/fragmento del path relativo para normalizar solo el path
197
+ let tail be ""
198
+ let core be h
199
+ let qi be split(h, "?")
200
+ let fi be split(h, "#")
201
+ when length(qi) > 1
202
+ set core to qi[0]
203
+ set tail to "?" + join(slice(qi, 1, length(qi)), "?")
204
+ otherwise when length(fi) > 1
205
+ set core to fi[0]
206
+ set tail to "#" + join(slice(fi, 1, length(fi)), "#")
207
+ when core == ""
208
+ give b["origin"] + b["path"] + tail
209
+ when starts_with(core, "/")
210
+ give b["origin"] + normalize_path(core) + tail
211
+ give b["origin"] + normalize_path(b["dir"] + core) + tail
212
+
213
+ -- nombre de archivo estable para la caché/spill de una URL: fetch-<host>-<digest>
214
+ export task slug(host)
215
+ let s be regex_replace(lower(text(host)), "[^a-z0-9._-]", "-")
216
+ when length(s) > 60
217
+ set s to slice(s, 0, 60)
218
+ when s == ""
219
+ give "page"
220
+ give s
package/lib/tools.syn CHANGED
@@ -19,6 +19,7 @@ use "./tools/ls.syn" as t_ls
19
19
  use "./tools/find.syn" as t_find
20
20
  use "./tools/grep.syn" as t_grep
21
21
  use "./tools/bash.syn" as t_bash
22
+ use "./tools/fetch.syn" as t_fetch
22
23
  use "./tools/skill.syn" as t_skill
23
24
  use "./tools/process.syn" as t_process
24
25
  use "./tools/memo.syn" as t_memo
@@ -28,6 +29,26 @@ use "./mcp.syn" as mcp
28
29
  use "./plugins.syn" as plugins
29
30
  use "./lsp.syn" as lsp
30
31
  use "./schedule.syn" as schedule
32
+ use "./computer.syn" as computer
33
+
34
+ -- la tool computer_use (lib/computer.syn): el escritorio y el navegador del usuario en segundo plano, sobre un
35
+ -- driver externo. Está en el registry siempre, pero al catálogo del modelo solo entra cuando está encendida y el
36
+ -- driver existe (agents.tool_names). Los args llegan planos del JSON del modelo y van a computer.call como map.
37
+ -- OJO: cada propiedad del SPEC tiene que ser un parámetro acá — un arg que no está da "unknown parameter" antes de
38
+ -- llegar a computer.call (2026-09-06: delivery_mode faltaba y el modelo no pudo apretar Enter en Chrome). Lo cubre
39
+ -- un test de unit.test.syn que recorre las propiedades del SPEC.
40
+ task computer_tool(action, pid, window_id, element, x, y, text, key, keys, modifiers, direction, amount, from_x, from_y, to_x, to_y, x1, y1, x2, y2, value, name, path, url, target_id, tab_id, ref, query, screenshot, max_elements, replace, delta_y, delta_x, profile, seconds, button, delivery_mode, option, app, capture_after, fields, goal, steps, enter)
41
+ require exec
42
+ require time
43
+ require env("LAMPSON_*")
44
+ require env("OS")
45
+ require file.read(".lampson")
46
+ require file.read(".lampson/*")
47
+ let a be {}
48
+ each e in [["pid", pid], ["window_id", window_id], ["element", element], ["x", x], ["y", y], ["text", text], ["key", key], ["keys", keys], ["modifiers", modifiers], ["direction", direction], ["amount", amount], ["from_x", from_x], ["from_y", from_y], ["to_x", to_x], ["to_y", to_y], ["x1", x1], ["y1", y1], ["x2", x2], ["y2", y2], ["value", value], ["name", name], ["path", path], ["url", url], ["target_id", target_id], ["tab_id", tab_id], ["ref", ref], ["query", query], ["screenshot", screenshot], ["max_elements", max_elements], ["replace", replace], ["delta_y", delta_y], ["delta_x", delta_x], ["profile", profile], ["seconds", seconds], ["button", button], ["delivery_mode", delivery_mode], ["option", option], ["app", app], ["capture_after", capture_after], ["fields", fields], ["goal", goal], ["steps", steps], ["enter", enter]]
49
+ when e[1] != nothing
50
+ set a[e[0]] to e[1]
51
+ give computer.call(action, a)
31
52
 
32
53
  -- la task de la tool skill (su SPEC está en tools/skill.syn)
33
54
  task skill_tool(name, action, source, scope)
@@ -256,6 +277,7 @@ export task registry()
256
277
  "find": t_find.tool,
257
278
  "grep": t_grep.tool,
258
279
  "bash": t_bash.tool,
280
+ "fetch": t_fetch.tool,
259
281
  "process": t_process.tool,
260
282
  "memory": t_memo.tool,
261
283
  "todo": t_todo.tool,
@@ -263,10 +285,11 @@ export task registry()
263
285
  "mcp": mcp_tool,
264
286
  "plugin": plugin_tool,
265
287
  "lsp": lsp_tool,
266
- "schedule": schedule_tool
288
+ "schedule": schedule_tool,
289
+ "computer_use": computer_tool
267
290
  }
268
291
 
269
- export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, PLUGIN_SPEC, SCHEDULE_SPEC]
292
+ export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_fetch.SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, PLUGIN_SPEC, SCHEDULE_SPEC, computer.SPEC]
270
293
 
271
294
  -- Subconjuntos (para perfiles de agente): registry/catálogo filtrados por nombre.
272
295
  export task registry_subset(names)
package/lib/trace.syn CHANGED
@@ -16,7 +16,7 @@ task path(sid)
16
16
  give DIR + "/" + sid + ".log"
17
17
 
18
18
  task one_line(s, max)
19
- let t be replace_text(replace_text(text(s), "\r", ""), "\n", " ⏎ ")
19
+ let t be replace(replace(text(s), "\r", ""), "\n", " ⏎ ")
20
20
  when length(t) > max
21
21
  give slice(t, 0, max) + "…"
22
22
  give t
@@ -105,7 +105,7 @@ export task tail(sid, n)
105
105
  require file(".lampson")
106
106
  require file(".lampson/*")
107
107
  try
108
- let lines be split(replace_text(read_file(path(sid)), "\r", ""), "\n")
108
+ let lines be split(replace(read_file(path(sid)), "\r", ""), "\n")
109
109
  when length(lines) > n
110
110
  set lines to slice(lines, length(lines) - n, length(lines))
111
111
  give join(lines, "\n")
package/lib/tree.syn CHANGED
@@ -3,35 +3,38 @@ use "./tools/common.syn" as c
3
3
 
4
4
  let MAX_ENTRIES be 2000
5
5
 
6
- task walk(dir, depth, max_depth, state)
6
+ -- n = entradas contadas hasta ahora en todo el árbol; da {items, n} (listas y mapas son valores: el contador vuelve con give)
7
+ task walk(dir, depth, max_depth, n)
7
8
  let out be []
8
9
  when depth > max_depth
9
- give out
10
+ give {"items": out, "n": n}
10
11
  let entries be []
11
12
  try
12
13
  set entries to list_dir(dir)
13
14
  recover err
14
- give out
15
+ give {"items": out, "n": n}
15
16
  each e in entries
16
- when state["n"] >= MAX_ENTRIES
17
- give out
17
+ when n >= MAX_ENTRIES
18
+ give {"items": out, "n": n}
18
19
  let rel be c.unws(dir + "/" + e["name"])
19
20
  when e["is_dir"]
20
21
  when not contains(c.IGNORED_DIRS, e["name"])
21
- set state["n"] to state["n"] + 1
22
- set out to append(out, {"name": e["name"], "path": rel, "is_dir": true, "children": walk(dir + "/" + e["name"], depth + 1, max_depth, state)})
22
+ set n to n + 1
23
+ let sub be walk(dir + "/" + e["name"], depth + 1, max_depth, n)
24
+ set n to sub["n"]
25
+ set out to append(out, {"name": e["name"], "path": rel, "is_dir": true, "children": sub["items"]})
23
26
  otherwise
24
- set state["n"] to state["n"] + 1
27
+ set n to n + 1
25
28
  set out to append(out, {"name": e["name"], "path": rel, "is_dir": false, "size": e["size"]})
26
29
  -- carpetas primero
27
- give where(out, (x) => x["is_dir"]) + where(out, (x) => not x["is_dir"])
30
+ give {"items": where(out, (x) => x["is_dir"]) + where(out, (x) => not x["is_dir"]), "n": n}
28
31
 
29
32
  -- árbol anidado: [{name, path, is_dir, size?, children?}]
30
33
  export task tree(max_depth)
31
34
  require file.read("workspace")
32
35
  require file.read("workspace/*")
33
- let state be {"n": 0}
34
- give {"entries": walk(c.ROOT, 0, max_depth, state), "count": state["n"], "truncated": state["n"] >= MAX_ENTRIES}
36
+ let w be walk(c.ROOT, 0, max_depth, 0)
37
+ give {"entries": w["items"], "count": w["n"], "truncated": w["n"] >= MAX_ENTRIES}
35
38
 
36
39
  -- render de texto para la terminal (2 niveles, compacto)
37
40
  export task render(max_depth, max_lines)
@@ -55,5 +58,18 @@ export task file_content(path)
55
58
  require file.read("workspace")
56
59
  require file.read("workspace/*")
57
60
  let real be c.ws(path)
58
- let content be read_file(real)
59
- give {"path": c.unws(real), "content": c.truncate(content, 200000), "lines": length(split(content, "\n"))}
61
+ let rel be c.unws(real)
62
+ let info be file_info(real)
63
+ when not info["exists"]
64
+ raise("\"" + rel + "\" does not exist")
65
+ when info["is_dir"]
66
+ raise("\"" + rel + "\" is a folder")
67
+ -- binarios (sqlite, imágenes, fuentes…): read_file no puede decodificarlos como UTF-8 y el runtime lo reporta
68
+ -- como "File not found" (engañoso; visto 2026-09-05 con un dev.db de Prisma). En vez de un error, {binary: true}
69
+ -- y el visor muestra su aviso, sin ensuciar el chat.
70
+ try
71
+ let content be read_file(real)
72
+ -- truncated: el editor de la web lo abre en solo lectura (guardar el recorte destruiría el archivo)
73
+ give {"path": rel, "content": c.truncate(content, 200000), "lines": length(split(content, "\n")), "truncated": length(content) > 200000, "binary": false, "size": info["size"]}
74
+ recover err
75
+ give {"path": rel, "content": "", "lines": 0, "truncated": false, "binary": true, "size": info["size"], "error": text(err)}
package/lib/update.syn CHANGED
@@ -12,7 +12,7 @@ task run_git(args, secs)
12
12
  let r be run("git", args, secs)
13
13
  when r["exit_code"] != 0
14
14
  give nothing
15
- give trim(replace_text(r["stdout"], "\r", ""))
15
+ give trim(replace(r["stdout"], "\r", ""))
16
16
  recover err
17
17
  give nothing
18
18