lampson 0.2.8 → 0.2.10

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 (81) 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/mobile.css +95 -0
  51. package/public/css/panel.css +30 -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 +16 -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 +4 -2
  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/mobile.js +122 -0
  69. package/public/js/procs.js +1 -1
  70. package/public/js/pwa.js +5 -0
  71. package/public/js/terminal.js +1 -1
  72. package/public/js/tree.js +12 -4
  73. package/public/login.html +43 -0
  74. package/public/manifest.webmanifest +16 -0
  75. package/public/offline.html +24 -0
  76. package/public/sw.js +63 -0
  77. package/public/vendor/highlight.min.js +1343 -0
  78. package/public/vendor/qrcode.js +2297 -0
  79. package/skills/computer-use/SKILL.md +178 -0
  80. package/skills/lampson/SKILL.md +5 -0
  81. package/web.syn +67 -6
package/lib/loop.syn CHANGED
@@ -27,6 +27,23 @@ use "./plugins.syn" as plugins
27
27
  use "./tools/todo.syn" as todo
28
28
 
29
29
  let MAX_ERRORS_PER_TURN be 8
30
+ -- capturas de computer_use (lib/computer.syn): el driver las deja en el blackboard "computer:screenshot" y acá van al
31
+ -- modelo como imagen adjunta a un mensaje de usuario (un tool result es texto en todos los proveedores). Solo la más
32
+ -- reciente viaja con la imagen; las anteriores quedan como nota (≈1500 tokens cada una, en CADA request).
33
+ let SHOT_PREFIX be "[harness] Screenshot from computer_use: "
34
+
35
+ export task drop_old_screenshots(msgs)
36
+ let out be []
37
+ each m in msgs
38
+ let is_shot be false
39
+ when m["role"] == "user"
40
+ when contains(m, "images")
41
+ set is_shot to starts_with(text(m["content"]), SHOT_PREFIX)
42
+ when is_shot
43
+ set out to append(out, {"role": "user", "content": text(m["content"]) + " [image removed to save context]"})
44
+ otherwise
45
+ set out to append(out, m)
46
+ give out
30
47
  -- Conciencia de progreso (2026-08-28, la mejor idea de cada harness — ver notes/*.md):
31
48
  -- * SPILL (deepseek): un resultado > SPILL_CAP se guarda entero en .lampson/spill/<call>.txt y el modelo ve
32
49
  -- cabeza + cola + "Full result saved to …"; `read` queda exento (ya pagina con offset/limit).
@@ -107,7 +124,7 @@ export task spill(name, id, out)
107
124
  let path be SPILL_DIR + "/" + id + ".txt"
108
125
  write_file(path, out)
109
126
  let half be floor(SPILL_CAP / 2)
110
- give slice(out, 0, half) + "\n\n[... " + text(length(out) - SPILL_CAP) + " chars omitted. Full result saved to " + path + " — grep it, or read it with offset/limit; do not re-run the command ...]\n\n" + slice(out, length(out) - half, length(out))
127
+ give slice(out, 0, half) + "\n\n[... " + text(length(out) - SPILL_CAP) + " chars omitted. Full result saved to " + path + " — use the grep or read TOOLS on that exact path (it lives in lampson, outside your workspace: bash cannot see it); do not re-run the command ...]\n\n" + slice(out, length(out) - half, length(out))
111
128
 
112
129
  -- recibo de una línea para un tool result viejo
113
130
  export task receipt(m)
@@ -116,13 +133,13 @@ export task receipt(m)
116
133
  give out
117
134
  let status be "ok"
118
135
  when starts_with(out, "ERROR") or starts_with(out, "DENIED")
119
- set status to slice(replace_text(split(out, "\n")[0], "\r", ""), 0, 120)
136
+ set status to slice(replace(split(out, "\n")[0], "\r", ""), 0, 120)
120
137
  otherwise
121
- let codes be find_all(out, "\\[exit code [0-9]+\\]")
138
+ let codes be regex_find_all(out, "\\[exit code [0-9]+\\]")
122
139
  when length(codes) > 0
123
140
  let last be codes[length(codes) - 1]
124
141
  set status to slice(last, 1, length(last) - 1)
125
- let saved be find_all(out, "Full result saved to [^ ]+")
142
+ let saved be regex_find_all(out, "Full result saved to [^ ]+")
126
143
  let where_full be when length(saved) > 0 then " · " + saved[0] otherwise ""
127
144
  give "[" + text(m["name"]) + " result pruned: " + status + " · " + text(length(split(out, "\n"))) + " lines/" + text(length(out)) + " chars" + where_full + " — re-run the tool only if you really need the content again]"
128
145
 
@@ -324,6 +341,8 @@ export task run_turn(cfg, messages, opts, on_event)
324
341
  let max_steps be opts["max_steps"]
325
342
  let budget be opts["budget_tokens"]
326
343
  let tag be opts["tag"]
344
+ -- ¿este modelo acepta imágenes? lo leen las tools que producen capturas (computer_use) para avisar al modelo
345
+ share provider.supports_vision(cfg) as "lampson:vision"
327
346
  -- compaction preventiva
328
347
  when estimate_tokens(msgs) > opts["compact_at_tokens"]
329
348
  emit(on_event, "compact", {"before": estimate_tokens(msgs)}, tag)
@@ -376,6 +395,11 @@ export task run_turn(cfg, messages, opts, on_event)
376
395
  emit(on_event, "usage", {"step": steps, "usage": r["usage"], "total": usage}, tag)
377
396
  let clean_text be strip_raw_tool_calls(r["text"])
378
397
  let assistant be {"role": "assistant", "content": clean_text, "tool_calls": r["tool_calls"]}
398
+ -- razonamiento del paso (solo lo trae el wire para los presets glm): va en el mensaje para
399
+ -- devolvérselo al modelo en el paso siguiente (thinking conservado, ver provider.chat_openai)
400
+ when contains(r, "reasoning")
401
+ when r["reasoning"] != ""
402
+ set assistant["reasoning"] to r["reasoning"]
379
403
  set msgs to append(msgs, assistant)
380
404
  when clean_text != ""
381
405
  emit(on_event, "assistant", clean_text, tag)
@@ -422,6 +446,14 @@ export task run_turn(cfg, messages, opts, on_event)
422
446
  set errors to errors + 1
423
447
  emit(on_event, "tool_result", {"call": tc, "output": out}, tag)
424
448
  set msgs to append(msgs, tool_result_msg(tc, out))
449
+ -- captura de pantalla pendiente (computer_use): adjunta como imagen si el proveedor tiene visión
450
+ observe "computer:screenshot" as shot
451
+ when shot != nothing
452
+ share nothing as "computer:screenshot"
453
+ when provider.supports_vision(cfg)
454
+ set msgs to drop_old_screenshots(msgs)
455
+ set msgs to append(msgs, {"role": "user", "content": SHOT_PREFIX + shot["label"] + ". Look at it, then continue the task.", "images": shot["images"]})
456
+ emit(on_event, "inbox", "captura de pantalla adjunta al modelo (" + shot["label"] + ")", tag)
425
457
  when errors >= MAX_ERRORS_PER_TURN
426
458
  set msgs to append(msgs, {"role": "user", "content": "[harness] Too many tool errors this turn. Stop calling tools and report what happened."})
427
459
  emit(on_event, "busy", {"label": "pensando", "kind": "llm"}, tag)
package/lib/lsp.syn CHANGED
@@ -92,13 +92,13 @@ export task servers()
92
92
  -- resuelven realpaths, así que el rootUri y los uri de los documentos tienen que ser los reales.
93
93
  export task root_path()
94
94
  require env("LAMPSON_*")
95
- let r be replace_text(env("LAMPSON_WORKSPACE", "workspace"), "\\", "/")
95
+ let r be replace(env("LAMPSON_WORKSPACE", "workspace"), "\\", "/")
96
96
  when c.ends_with(r, "/")
97
97
  set r to slice(r, 0, length(r) - 1)
98
98
  give r
99
99
 
100
100
  export task to_uri(path)
101
- let p be replace_text(replace_text(path, "\\", "/"), " ", "%20")
101
+ let p be replace(replace(path, "\\", "/"), " ", "%20")
102
102
  when matches(p, "[A-Za-z]:/.*")
103
103
  give "file:///" + p
104
104
  give "file://" + p
@@ -109,7 +109,7 @@ export task from_uri(uri)
109
109
  set u to slice(u, 8, length(u))
110
110
  otherwise when starts_with(u, "file://")
111
111
  set u to slice(u, 7, length(u))
112
- give replace_text(replace_text(replace_text(u, "%3A", ":"), "%3a", ":"), "%20", " ")
112
+ give replace(replace(replace(u, "%3A", ":"), "%3a", ":"), "%20", " ")
113
113
 
114
114
  -- uri del server → ruta relativa al workspace (o absoluta si cae fuera)
115
115
  export task relativize(uri, root)
@@ -123,7 +123,7 @@ export task relativize(uri, root)
123
123
  give p
124
124
 
125
125
  task ext_of(path)
126
- let parts be split(replace_text(path, "\\", "/"), "/")
126
+ let parts be split(replace(path, "\\", "/"), "/")
127
127
  let base be parts[length(parts) - 1]
128
128
  let segs be split(base, ".")
129
129
  when length(segs) < 2
package/lib/mcp.syn CHANGED
@@ -109,6 +109,24 @@ agent McpSup
109
109
  let sub be bus_subscribe(["mcp.req." + name, "mcp.stop." + name, "mcp.stop_all"])
110
110
  -- request síncrona (handshake y tools/list): manda y espera la respuesta con ese id, saltando
111
111
  -- notificaciones y basura; devuelve {result} | {error} | nothing (timeout / proceso muerto)
112
+ -- MIGA runtime (2026-09-06): proc_spawn en line_mode parte las líneas de más de 1 MiB en trozos de 1 MiB; un
113
+ -- resultado grande (una captura base64) llegaba en trozos que no decodificaban y la respuesta se perdía.
114
+ -- `acc` junta los trozos hasta que el JSON cierra; un trozo suelto que decodifica solo reinicia el acumulador.
115
+ let acc be ""
116
+ task decode_line(data)
117
+ let joined be acc + data
118
+ try
119
+ let m be json_decode(joined)
120
+ set acc to ""
121
+ give m
122
+ recover e1
123
+ try
124
+ let m2 be json_decode(data)
125
+ set acc to ""
126
+ give m2
127
+ recover e2
128
+ set acc to when length(joined) > 40000000 then "" otherwise joined
129
+ give nothing
112
130
  task rpc(method, params, timeout)
113
131
  set seq to seq + 1
114
132
  let id be seq
@@ -122,12 +140,10 @@ agent McpSup
122
140
  otherwise when ev["type"] == "exit"
123
141
  set answer to {"error": "server exited with code " + text(ev["data"]["exit_code"])}
124
142
  otherwise when ev["type"] == "stdout"
125
- try
126
- let m be json_decode(ev["data"])
143
+ let m be decode_line(ev["data"])
144
+ when m != nothing
127
145
  when contains(m, "id") and m["id"] == id
128
146
  set answer to when contains(m, "error") then {"error": text(m["error"]["message"])} otherwise {"result": m["result"]}
129
- recover e2
130
- set answer to answer
131
147
  give answer
132
148
  task notify(method, params)
133
149
  proc_send(p, json_encode({"jsonrpc": "2.0", "method": method, "params": params}) + "\n")
package/lib/md.syn CHANGED
@@ -21,9 +21,9 @@ task width_now()
21
21
  task paths(s, color)
22
22
  when not color
23
23
  give s
24
- let out be replace_re(s, "(^|[\s(])([\w.-]*/[\w./-]{2,})", "\1" + ESC + "[34m\2" + ESC + "[0m")
25
- set out to replace_re(out, "(^|[\s(])(\w+\.(syn|js|ts|tsx|jsx|json|md|py|rs|go|ps1|sh|html|css|toml|yml|yaml|env))(\b)", "\1" + ESC + "[34m\2" + ESC + "[0m")
26
- set out to replace_re(out, "(^|[\s(])(\w+\(\))", "\1" + ESC + "[34m\2" + ESC + "[0m")
24
+ let out be regex_replace(s, "(^|[\s(])([\w.-]*/[\w./-]{2,})", "\1" + ESC + "[34m\2" + ESC + "[0m")
25
+ set out to regex_replace(out, "(^|[\s(])(\w+\.(syn|js|ts|tsx|jsx|json|md|py|rs|go|ps1|sh|html|css|toml|yml|yaml|env))(\b)", "\1" + ESC + "[34m\2" + ESC + "[0m")
26
+ set out to regex_replace(out, "(^|[\s(])(\w+\(\))", "\1" + ESC + "[34m\2" + ESC + "[0m")
27
27
  give out
28
28
 
29
29
  task sgr(color, code, s)
@@ -42,16 +42,16 @@ task rep(ch, n)
42
42
  task emphasis(s, color)
43
43
  let out be s
44
44
  -- links [texto](url) → texto url (antes que la cursiva: la url puede traer _ o *)
45
- set out to replace_re(out, "\[([^\]]+)\]\(([^)]+)\)", sgr(color, "4", "\1") + sgr(color, "2", " \2"))
46
- set out to replace_re(out, "\*\*([^*]+)\*\*", sgr(color, "1", "\1"))
47
- set out to replace_re(out, "__([^_]+)__", sgr(color, "1", "\1"))
48
- set out to replace_re(out, "~~([^~]+)~~", sgr(color, "9", "\1"))
45
+ set out to regex_replace(out, "\[([^\]]+)\]\(([^)]+)\)", sgr(color, "4", "\1") + sgr(color, "2", " \2"))
46
+ set out to regex_replace(out, "\*\*([^*]+)\*\*", sgr(color, "1", "\1"))
47
+ set out to regex_replace(out, "__([^_]+)__", sgr(color, "1", "\1"))
48
+ set out to regex_replace(out, "~~([^~]+)~~", sgr(color, "9", "\1"))
49
49
  -- cursiva: *x* solo si no está pegada a texto/número (2*3 no es cursiva). Dos pasadas:
50
50
  -- el grupo \3 consume el separador, así que "*a* *b*" necesita la segunda.
51
51
  let pass be 0
52
52
  while pass < 2
53
- set out to replace_re(out, "(^|[^\w*])\*([^*\s][^*]*?)\*($|[^\w*])", "\1" + sgr(color, "3", "\2") + "\3")
54
- set out to replace_re(out, "(^|[^\w_])_([^_\s][^_]*?)_($|[^\w_])", "\1" + sgr(color, "3", "\2") + "\3")
53
+ set out to regex_replace(out, "(^|[^\w*])\*([^*\s][^*]*?)\*($|[^\w*])", "\1" + sgr(color, "3", "\2") + "\3")
54
+ set out to regex_replace(out, "(^|[^\w_])_([^_\s][^_]*?)_($|[^\w_])", "\1" + sgr(color, "3", "\2") + "\3")
55
55
  set pass to pass + 1
56
56
  give out
57
57
 
@@ -162,8 +162,8 @@ export task render(md, color)
162
162
  let table be []
163
163
  each line in split(md, "\n")
164
164
  let t be trim(line)
165
- let heading be capture(t, "^(#{1,6}) (.+)$")
166
- let item be capture(t, "^(\d{1,3})[.)] (.*)$")
165
+ let heading be regex_capture(t, "^(#{1,6}) (.+)$")
166
+ let item be regex_capture(t, "^(\d{1,3})[.)] (.*)$")
167
167
  let is_row be starts_with(t, "|") and ends_with(t, "|") and not in_code
168
168
  when length(table) > 0 and not is_row
169
169
  each l in table_lines(table, pre, color)
package/lib/origin.syn ADDED
@@ -0,0 +1,67 @@
1
+ -- lib/origin.syn — de dónde viene una petición HTTP: la usan el hub (hub.tpl.syn, lib/auth.syn) y cada workspace (web.syn).
2
+ --
3
+ -- «Local» (esta PC, entra sin passkey) se PRUEBA con las tres cosas a la vez, nunca se deduce de lo que falta:
4
+ -- 1. el peer es loopback;
5
+ -- 2. no pasó por ningún proxy (sin X-Forwarded-*, Forwarded ni los headers de cloudflared);
6
+ -- 3. el Host CRUDO es un nombre de loopback. Un túnel (syn tunnel, cloudflared, ngrok) o el edge de --domain
7
+ -- traen el Host público; una página con DNS rebinding trae el suyo.
8
+ -- Desde el motor v0.6.42 los X-Forwarded-* de un peer que no está en `trust proxy` se borran antes del programa. El
9
+ -- hub y los workspaces confían en loopback (`trust proxy` / `--trust-proxy`): ahí conectan los túneles, el edge y el
10
+ -- proxy del hub, así que lo que dicen llega y `ip of request` es el cliente real. Sin eso, un syn tunnel entraba
11
+ -- como «esta PC» (2026-10-05). Un túnel TCP crudo (ssh -R, ngrok tcp) no deja rastro y no se puede distinguir:
12
+ -- nunca se apunta uno al hub.
13
+ --
14
+ -- Otra origin en el navegador — CSRF, un WebSocket abierto desde otra página, DNS rebinding — se rechaza siempre,
15
+ -- local o remoto: un navegador manda Origin en todo POST, en todo WebSocket y en los fetch entre origins, y tiene
16
+ -- que coincidir con la origin de la propia petición. Sin Origin (curl, scripts, navegación) no hay sitio ajeno.
17
+ -- Módulo puro: sin require.
18
+
19
+ let FORWARD_HEADERS be ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded", "x-real-ip", "cf-connecting-ip", "cf-ray"]
20
+ let LOOPBACK_NAMES be ["localhost", "127.0.0.1", "::1"]
21
+
22
+ export task is_loopback(ip)
23
+ give ip == "127.0.0.1" or ip == "::1" or ip == "::ffff:127.0.0.1"
24
+
25
+ -- valor de Host (o X-Forwarded-Host) → nombre sin puerto ni corchetes, en minúsculas
26
+ export task host_name(value)
27
+ let h be trim(split(lower(text(value)), ",")[0])
28
+ when starts_with(h, "[")
29
+ give split(slice(h, 1, length(h)), "]")[0]
30
+ give split(h, ":")[0]
31
+
32
+ -- "host[:puerto]" como lo vio el navegador: el que puso el proxy de confianza, o el Host si no hubo proxy
33
+ task authority(headers)
34
+ give trim(split(lower(text(get(headers, "x-forwarded-host", get(headers, "host", "")))), ",")[0])
35
+
36
+ -- el Host que llegó a ESTE servidor (un proxy lo reescribe al suyo; el de afuera queda en X-Forwarded-Host)
37
+ export task raw_host(headers)
38
+ give host_name(get(headers, "host", ""))
39
+
40
+ -- el dominio que ve el usuario: rp_id de WebAuthn
41
+ export task public_host(headers)
42
+ give host_name(authority(headers))
43
+
44
+ -- la origin que ve el navegador. Lampson no termina TLS en el hub ni en los workspaces: sin un proxy de confianza
45
+ -- que diga https (el edge, un túnel), es http
46
+ export task origin_of(headers)
47
+ let proto be lower(trim(split(text(get(headers, "x-forwarded-proto", "")), ",")[0]))
48
+ when proto == ""
49
+ set proto to "http"
50
+ give proto + "://" + authority(headers)
51
+
52
+ export task forwarded(headers)
53
+ each h in FORWARD_HEADERS
54
+ when contains(headers, h)
55
+ give true
56
+ give false
57
+
58
+ export task loopback_host(headers)
59
+ give contains(LOOPBACK_NAMES, raw_host(headers))
60
+
61
+ export task cross_site(headers)
62
+ when not contains(headers, "origin")
63
+ give false
64
+ give lower(trim(text(headers["origin"]))) != origin_of(headers)
65
+
66
+ export task is_local(ip, headers)
67
+ give is_loopback(ip) and not forwarded(headers) and loopback_host(headers)
@@ -10,6 +10,7 @@
10
10
  -- Modos (LAMPSON_PERMISSION): "ask" (default) | "yolo" (dangerous → allow) | "strict" (dangerous → deny)
11
11
 
12
12
  use "./tools/url.syn" as u
13
+ use "./computer.syn" as computer
13
14
 
14
15
  -- Tier 1: nunca. Coincidencia por substring, case-insensitive.
15
16
  -- (el comando se evalúa con un espacio final añadido, así "rm -rf / " matchea la raíz pero NO "rm -rf /tmp/x")
@@ -94,7 +95,7 @@ export task evaluate(name, args, mode)
94
95
  give {"decision": "allow", "reason": "public URL"}
95
96
  when name == "write" or name == "edit"
96
97
  -- el scope file("./*") ya impide salir del workspace; aquí solo miramos secretos obvios
97
- let p be when contains(args, "path") then lower(replace_text(text(args["path"]), "\\", "/")) otherwise ""
98
+ let p be when contains(args, "path") then lower(replace(text(args["path"]), "\\", "/")) otherwise ""
98
99
  let parts be split(p, "/")
99
100
  let base be parts[length(parts) - 1]
100
101
  when is_secret_env(base)
@@ -153,11 +154,27 @@ export task evaluate(name, args, mode)
153
154
  when mode == "strict"
154
155
  give {"decision": "deny", "reason": "strict mode (installs a third-party skill)"}
155
156
  give {"decision": "ask", "reason": "installs a third-party skill from " + (when contains(args, "source") then text(args["source"]) otherwise "?") + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"}
157
+ when name == "computer_use"
158
+ -- el escritorio del usuario (lib/computer.syn): leer (ventanas, árbol, capturas, texto) siempre; actuar pide UNA
159
+ -- sola vez por corrida — la primera acción — y cubre todo lo demás (teclas en foreground, abrir apps y
160
+ -- navegadores): pedir por cada cosa hacía inútil la tool (2026-09-06: cuatro pedidos para llenar un form).
161
+ -- La tool deja "computer:grant" en el blackboard al ejecutar la primera aprobada. strict deniega actuar.
162
+ let cact be when contains(args, "action") then lower(text(args["action"])) otherwise ""
163
+ when not contains(computer.ACTING, cact)
164
+ give {"decision": "allow", "reason": "read-only tool"}
165
+ when mode == "yolo"
166
+ give {"decision": "allow", "reason": "yolo mode (computer " + cact + ")"}
167
+ when mode == "strict"
168
+ give {"decision": "deny", "reason": "strict mode (computer " + cact + ")"}
169
+ observe "computer:grant" as granted
170
+ when granted == true
171
+ give {"decision": "allow", "reason": "desktop control granted this run"}
172
+ give {"decision": "ask", "reason": "takes control of your desktop for the rest of this run — clicks and keys in the background (briefly to the front when an app needs it), opening apps and browser windows; asked once"}
156
173
  give {"decision": "allow", "reason": "read-only tool"}
157
174
 
158
175
  -- Resumen legible de una tool call, para mostrar al humano antes de aprobar / en el log.
159
176
  task one_line(s, max)
160
- let t be replace_text(replace_text(s, "\r", ""), "\n", " ⏎ ")
177
+ let t be replace(replace(s, "\r", ""), "\n", " ⏎ ")
161
178
  when length(t) > max
162
179
  give slice(t, 0, max) + "…"
163
180
  give t
@@ -165,6 +182,8 @@ task one_line(s, max)
165
182
  export task describe_call(name, args)
166
183
  when starts_with(name, "mcp_") or starts_with(name, "plugin_")
167
184
  give name + " " + one_line(json_encode(args), 160)
185
+ when name == "computer_use"
186
+ give computer.describe(args)
168
187
  when name == "lsp" and contains(args, "op") and text(args["op"]) == "add"
169
188
  give "lsp add " + (when contains(args, "server") then text(args["server"]) otherwise "?") + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
170
189
  when name == "lsp"
package/lib/prompt.syn CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  use "./skills.syn" as skills
8
8
  use "./tools/memo.syn" as memo
9
+ use "./computer.syn" as computer
9
10
 
10
11
  let CONTEXT_FILES be ["AGENTS.md", "LAMPSON.md"]
11
12
 
@@ -22,13 +23,20 @@ task read_context_files()
22
23
  give out
23
24
 
24
25
  export task build(env_info, addendum)
26
+ require exec
27
+ require time
25
28
  require file.read("workspace")
26
29
  require file.read("workspace/*")
27
30
  require file.read("skills")
28
31
  require file.read("skills/*")
29
32
  require file.read("memory")
30
33
  require file.read("memory/*")
34
+ require file.read(".lampson")
35
+ require file.read(".lampson/*")
31
36
  require env("LAMPSON_*")
37
+ require env("OS")
38
+ -- computer_use solo se menciona cuando está en el catálogo (encendida + driver): vacío si no
39
+ let cu be computer.prompt_line()
32
40
  let core be `You are Lampson, a coding agent that works inside the user's project through tools.
33
41
  Operate like a careful senior engineer: precise, honest, and economical with words.
34
42
 
@@ -60,7 +68,7 @@ Operate like a careful senior engineer: precise, honest, and economical with wor
60
68
  - memory: durable facts across sessions (user preferences, environment details, tool quirks, conventions). Check it before investigating. Write declarative facts, not instructions to yourself; do not store task progress or anything stale in a week. The notes live outside the project: only this tool reaches them (never ls/bash, never files inside the repo).
61
69
  - skill: before starting a kind of task, scan the skill list below; if one matches or is even partially relevant, load it and follow it. Err on the side of loading.
62
70
  - todo: your task list (whole-list replacement, one in_progress at a time; see Method step 1).
63
- - MCP tools are named mcp_<server>_<tool>; calling one may ask the user for approval. The mcp tool lists, connects (action=add, with the user's approval) and disconnects servers; a new server's tools enter your catalog on the next turn.
71
+ - MCP tools are named mcp_<server>_<tool>; calling one may ask the user for approval. The mcp tool lists, connects (action=add, with the user's approval) and disconnects servers; a new server's tools enter your catalog on the next turn.{cu}
64
72
  - Call several independent tools in one turn (reads, searches, read-only commands); serialize only when a later call depends on an earlier result. Tool outputs are truncated at 30k chars — narrow your queries.
65
73
 
66
74
  # Tone
package/lib/provider.syn CHANGED
@@ -37,15 +37,39 @@ export let PRESETS be {
37
37
  "ollama": {"wire": "openai", "base_url": "http://127.0.0.1:11434/v1", "model": "qwen2.5-coder"},
38
38
  "anthropic": {"wire": "anthropic", "base_url": "https://api.anthropic.com/v1", "model": "claude-sonnet-4-6"},
39
39
  "minimax": {"wire": "anthropic", "base_url": "https://api.minimax.io/anthropic/v1", "model": "MiniMax-M3"},
40
- -- Z.ai (Zhipu): API compatible con OpenAI. Coding Plan: LAMPSON_BASE_URL=https://api.z.ai/api/coding/paas/v4
41
- "glm": {"wire": "openai", "base_url": "https://api.z.ai/api/paas/v4", "model": "glm-5.3"}
40
+ -- Z.ai (Zhipu), API compatible con OpenAI, dos presets con la MISMA key (es la misma cuenta):
41
+ -- `glm` = pago por uso (saldo prepago); `glm-coding` = GLM Coding Plan (suscripción), que solo vale
42
+ -- en el endpoint /coding/. La key de un Coding Plan contra `glm` da 429 código 1113 "Insufficient
43
+ -- balance or no resource package" (visto 2026-09-05): no es la key, es el endpoint.
44
+ -- Modelo por defecto glm-5.3-flash (2026-09-07): el primer GLM-5 multimodal nativo (imágenes por
45
+ -- image_url, verificado con un PNG en data URL), 1M de contexto, 128K de salida y más rápido que
46
+ -- glm-5.3; ambos endpoints lo listan en GET /models. Su thinking no se puede apagar (ver chat_openai).
47
+ "glm": {"wire": "openai", "base_url": "https://api.z.ai/api/paas/v4", "model": "glm-5.3-flash"},
48
+ "glm-coding": {"wire": "openai", "base_url": "https://api.z.ai/api/coding/paas/v4", "model": "glm-5.3-flash"}
42
49
  }
43
50
 
44
51
  -- Keys: una por proveedor en .env como LAMPSON_API_KEY_<PROVIDER> (LAMPSON_API_KEY_DEEPSEEK, _ANTHROPIC…);
45
52
  -- LAMPSON_API_KEY es la del proveedor por defecto (LAMPSON_PROVIDER). Así se cambia de proveedor en caliente
46
53
  -- (/provider en terminal, selector en la web) sin tocar .env. Solo se comprueba SI hay key, nunca se muestra.
47
- task key_var(name)
48
- give "LAMPSON_API_KEY_" + upper(name)
54
+ -- (`glm-coding` → LAMPSON_API_KEY_GLM_CODING: un guion no es válido en un nombre de variable)
55
+ export task key_var(name)
56
+ give "LAMPSON_API_KEY_" + upper(join(split(name, "-"), "_"))
57
+
58
+ -- Presets que comparten cuenta y key (z.ai): si uno no tiene key propia, usa la del otro.
59
+ let KEY_ALIASES be {"glm-coding": "glm", "glm": "glm-coding"}
60
+
61
+ -- Nombre bajo el que hay key para `n` (config.json o LAMPSON_API_KEY_<P>): él mismo, si no su alias, si no "".
62
+ task key_owner(n)
63
+ require env("LAMPSON_*")
64
+ require file.read(".lampson")
65
+ require file.read(".lampson/*")
66
+ when settings.raw_key(n) != "" or env(key_var(n), "") != ""
67
+ give n
68
+ when contains(KEY_ALIASES, n)
69
+ let a be KEY_ALIASES[n]
70
+ when settings.raw_key(a) != "" or env(key_var(a), "") != ""
71
+ give a
72
+ give ""
49
73
 
50
74
  -- proveedor por defecto: config.json (onboarding / UI) > LAMPSON_PROVIDER en .env > deepseek
51
75
  task env_default()
@@ -67,9 +91,7 @@ export task has_key(name)
67
91
  require file.read(".lampson/*")
68
92
  when name == "ollama"
69
93
  give true
70
- when settings.raw_key(name) != ""
71
- give true
72
- when env(key_var(name), "") != ""
94
+ when key_owner(name) != ""
73
95
  give true
74
96
  give name == env_default() and env("LAMPSON_API_KEY", "") != ""
75
97
 
@@ -97,7 +119,7 @@ export task providers()
97
119
  -- Nombres de modelo: DeepSeek, OpenAI, Anthropic, Groq, Kimi, Grok y OpenRouter publican ids en minúsculas
98
120
  -- y la API rechaza otra capitalización ("deepseek-v4-pro" sí, "DeepSeek-V4-Pro" → 400 invalid_request_error,
99
121
  -- visto 2026-08-27 con un nombre tipeado en /setup). MiniMax ("MiniMax-M3") y Ollama (tags) se dejan tal cual.
100
- let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter", "glm"]
122
+ let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter", "glm", "glm-coding"]
101
123
 
102
124
  export task normalize_model(name, model)
103
125
  let m be trim(text(model))
@@ -163,12 +185,15 @@ export task config_for(name, model)
163
185
  when model != nothing and model != ""
164
186
  set m to model
165
187
  set m to normalize_model(n, m)
166
- -- key: guardada en config.json > LAMPSON_API_KEY_<P> > LAMPSON_API_KEY (solo para el proveedor de .env)
188
+ -- key: guardada en config.json > LAMPSON_API_KEY_<P> > LAMPSON_API_KEY (solo para el proveedor de .env).
189
+ -- Si el preset no tiene key propia se usa la de su alias (KEY_ALIASES: glm ↔ glm-coding).
167
190
  let key be secret("LAMPSON_API_KEY", "ollama")
168
- when env(key_var(n), "") != ""
169
- set key to secret(key_var(n), "ollama")
170
- when settings.raw_key(n) != ""
171
- set key to as_secret(settings.raw_key(n), "api_key_" + n)
191
+ let ko be key_owner(n)
192
+ when ko != ""
193
+ when env(key_var(ko), "") != ""
194
+ set key to secret(key_var(ko), "ollama")
195
+ when settings.raw_key(ko) != ""
196
+ set key to as_secret(settings.raw_key(ko), "api_key_" + ko)
172
197
  give {
173
198
  "provider": n,
174
199
  "wire": wire,
@@ -200,7 +225,15 @@ task err_result(msg)
200
225
 
201
226
  -- ---------- OpenAI wire (chat/completions) ----------
202
227
 
203
- task to_openai_messages(messages)
228
+ -- Presets de Z.ai: comparten cuenta y key, y son los únicos que reciben `thinking` y el eco del razonamiento.
229
+ task is_glm(cfg)
230
+ when not contains(cfg, "provider")
231
+ give false
232
+ give cfg["provider"] == "glm" or cfg["provider"] == "glm-coding"
233
+
234
+ -- keep_reasoning: devolver el `reasoning` guardado en cada assistant como `reasoning_content` (solo Z.ai;
235
+ -- otros endpoints podrían rechazar el campo). Ver chat_openai.
236
+ task to_openai_messages(messages, keep_reasoning)
204
237
  let out be []
205
238
  each m in messages
206
239
  when m["role"] == "assistant"
@@ -211,6 +244,10 @@ task to_openai_messages(messages)
211
244
  each tc in m["tool_calls"]
212
245
  set tcs to append(tcs, {"id": tc["id"], "type": "function", "function": {"name": tc["name"], "arguments": json_encode(tc["args"])}})
213
246
  set entry to {"role": "assistant", "content": m["content"], "tool_calls": tcs}
247
+ when keep_reasoning and contains(m, "reasoning")
248
+ when m["reasoning"] != nothing
249
+ when m["reasoning"] != ""
250
+ set entry["reasoning_content"] to m["reasoning"]
214
251
  set out to append(out, entry)
215
252
  otherwise when m["role"] == "tool"
216
253
  set out to append(out, {"role": "tool", "tool_call_id": m["tool_call_id"], "content": m["content"]})
@@ -257,9 +294,10 @@ export task supports_vision(cfg)
257
294
  give contains(m, "vision") or starts_with(m, "grok-4")
258
295
  when p == "kimi"
259
296
  give contains(m, "vision") or contains(m, "vl")
260
- -- GLM: los modelos con visión llevan sufijo v (glm-4.5v, glm-4.6v)
261
- when p == "glm"
262
- give contains(m, "vision") or ends_with(m, "v") or contains(m, "v-")
297
+ -- GLM: los modelos con visión llevan sufijo v (glm-4.5v, glm-4.6v); glm-5.3-flash es el primer GLM-5
298
+ -- multimodal nativo (glm-5.3 a secas NO acepta imágenes). Verificado 2026-09-07 con un PNG por data URL.
299
+ when p == "glm" or p == "glm-coding"
300
+ give contains(m, "vision") or ends_with(m, "v") or contains(m, "v-") or starts_with(m, "glm-5.3-flash")
263
301
  give false
264
302
 
265
303
  -- Modelo sin visión: la imagen se reemplaza por una nota que el modelo puede explicarle al usuario
@@ -311,9 +349,16 @@ task parse_args(raw)
311
349
  give {"_raw": raw, "_parse_error": err}
312
350
 
313
351
  task chat_openai(cfg, messages, catalog)
314
- let body be {"model": cfg["model"], "messages": to_openai_messages(messages), "max_tokens": cfg["max_tokens"]}
352
+ let glm be is_glm(cfg)
353
+ let body be {"model": cfg["model"], "messages": to_openai_messages(messages, glm), "max_tokens": cfg["max_tokens"]}
315
354
  when length(catalog) > 0
316
- set body to {"model": cfg["model"], "messages": to_openai_messages(messages), "max_tokens": cfg["max_tokens"], "tools": to_openai_tools(catalog)}
355
+ set body["tools"] to to_openai_tools(catalog)
356
+ -- Z.ai: el thinking de glm-5.3-flash no se puede apagar y la doc recomienda clear_thinking=false, que
357
+ -- conserva el razonamiento entre pasos de un turno SI cada assistant vuelve con su reasoning_content
358
+ -- (por eso se guarda `reasoning` en el mensaje y to_openai_messages lo ecoa). Solo para los presets
359
+ -- glm: otros endpoints OpenAI rechazan parámetros desconocidos. Verificado 2026-09-07: 200 con y sin eco.
360
+ when glm
361
+ set body["thinking"] to {"type": "enabled", "clear_thinking": false}
317
362
  let headers be {"Authorization": bearer(cfg["key"]), "Content-Type": "application/json"}
318
363
  let r be http_post(strip_slash(cfg["base_url"]) + "/chat/completions", json_encode(body), headers, cfg["timeout"])
319
364
  when contains(r, "error")
@@ -331,6 +376,11 @@ task chat_openai(cfg, messages, catalog)
331
376
  when msg["tool_calls"] != nothing
332
377
  each tc in msg["tool_calls"]
333
378
  set calls to append(calls, {"id": tc["id"], "name": tc["function"]["name"], "args": parse_args(tc["function"]["arguments"])})
379
+ -- razonamiento del paso (reasoning_content): se guarda solo para Z.ai, que es quien lo vuelve a pedir
380
+ let reasoning be ""
381
+ when glm and contains(msg, "reasoning_content")
382
+ when msg["reasoning_content"] != nothing
383
+ set reasoning to text(msg["reasoning_content"])
334
384
  let usage be {"input": 0, "output": 0, "cached": 0}
335
385
  when contains(j, "usage")
336
386
  when j["usage"] != nothing
@@ -348,7 +398,7 @@ task chat_openai(cfg, messages, catalog)
348
398
  give {"text": "", "tool_calls": [], "usage": usage, "stop": "end", "error": "empty response: the model used its whole output budget (max_tokens=" + text(cfg["max_tokens"]) + ") without emitting text or tool calls — a reasoning model can spend it all thinking. Raise LAMPSON_MAX_TOKENS (or simplify the request) and say «seguí»."}
349
399
  set txt to txt + "\n\n[respuesta cortada: se alcanzó max_tokens=" + text(cfg["max_tokens"]) + " — decí «seguí» para que continúe]"
350
400
  let finish be when length(calls) > 0 then "tool_calls" otherwise "end"
351
- give {"text": txt, "tool_calls": calls, "usage": usage, "stop": finish, "error": nothing}
401
+ give {"text": txt, "tool_calls": calls, "usage": usage, "stop": finish, "error": nothing, "reasoning": reasoning}
352
402
 
353
403
  -- ---------- Anthropic wire (messages) ----------
354
404
 
package/lib/schedule.syn CHANGED
@@ -70,7 +70,7 @@ export task tz_offset()
70
70
 
71
71
  -- "+0200" | "-03:00" | "-3" → segundos
72
72
  export task parse_offset(raw)
73
- let s be replace_text(trim(raw), ":", "")
73
+ let s be replace(trim(raw), ":", "")
74
74
  when s == ""
75
75
  give 0
76
76
  let sign be when starts_with(s, "-") then -1 otherwise 1
@@ -126,8 +126,8 @@ task parse_span(tok)
126
126
  let t be lower(tok)
127
127
  when not matches(t, "[0-9]+(\.[0-9]+)?(s|m|h|d|min|h?ora?s?|d[ií]as?|seg(undos)?|minutos?)?")
128
128
  give nothing
129
- let num be number(capture(t, "[0-9]+(?:\.[0-9]+)?"))
130
- let unit be replace_re(t, "^[0-9.]+", "")
129
+ let num be number(regex_capture(t, "[0-9]+(?:\.[0-9]+)?")[0])
130
+ let unit be regex_replace(t, "^[0-9.]+", "")
131
131
  when unit == "s" or starts_with(unit, "seg")
132
132
  give floor(num)
133
133
  when unit == "m" or starts_with(unit, "min")
@@ -139,9 +139,9 @@ task parse_span(tok)
139
139
  -- → {ok, plan: {kind: every|daily|weekly, seconds?, hh?, mm?, days?}, error}
140
140
  export task parse_when(spec)
141
141
  let s be lower(trim(text(spec)))
142
- set s to replace_text(replace_text(replace_text(s, " a las ", " "), " at ", " "), "todos los días", "daily")
143
- set s to replace_text(replace_text(s, "todos los dias", "daily"), "every day", "daily")
144
- let toks be where(split(replace_text(s, ",", " "), " "), (x) => x != "")
142
+ set s to replace(replace(replace(s, " a las ", " "), " at ", " "), "todos los días", "daily")
143
+ set s to replace(replace(s, "todos los dias", "daily"), "every day", "daily")
144
+ let toks be where(split(replace(s, ",", " "), " "), (x) => x != "")
145
145
  when length(toks) == 0
146
146
  give {"ok": false, "error": "empty schedule"}
147
147
  let head be toks[0]
@@ -335,7 +335,7 @@ export task mount_ok()
335
335
  give true
336
336
 
337
337
  task norm_path(p)
338
- let s be lower(replace_text(trim(p), "\\", "/"))
338
+ let s be lower(replace(trim(p), "\\", "/"))
339
339
  -- Git Bash: /c/Users/x → c:/users/x
340
340
  when matches(s, "/[a-z]/.*")
341
341
  set s to slice(s, 1, 2) + ":" + slice(s, 2, length(s))
@@ -369,8 +369,8 @@ task put(t)
369
369
  give t
370
370
 
371
371
  task slug(name)
372
- let s be lower(replace_re(text(name), "[^A-Za-z0-9]+", "-"))
373
- set s to replace_re(replace_re(s, "^-+", ""), "-+$", "")
372
+ let s be lower(regex_replace(text(name), "[^A-Za-z0-9]+", "-"))
373
+ set s to regex_replace(regex_replace(s, "^-+", ""), "-+$", "")
374
374
  when s == ""
375
375
  give "task"
376
376
  when length(s) > 24
@@ -471,7 +471,7 @@ task default_name(action)
471
471
  when action["type"] == "bash"
472
472
  let cmd be action["command"]
473
473
  give when length(cmd) > 40 then slice(cmd, 0, 40) + "…" otherwise cmd
474
- let p be replace_text(action["prompt"], "\n", " ")
474
+ let p be replace(action["prompt"], "\n", " ")
475
475
  give when length(p) > 40 then slice(p, 0, 40) + "…" otherwise p
476
476
 
477
477
  export task remove(id)
@@ -628,7 +628,7 @@ export task record(t, started, status, output, extra)
628
628
  give fresh
629
629
 
630
630
  task one_line(s, max)
631
- let t be trim(replace_text(replace_text(text(s), "\r", ""), "\n", " ⏎ "))
631
+ let t be trim(replace(replace(text(s), "\r", ""), "\n", " ⏎ "))
632
632
  when length(t) > max
633
633
  give slice(t, 0, max) + "…"
634
634
  give t
@@ -646,7 +646,7 @@ export task log_tail(id, n)
646
646
  when not matches(text(id), "[a-z0-9-]{1,40}")
647
647
  give ""
648
648
  try
649
- let lines be split(replace_text(read_file(LOG_DIR + "/" + id + ".log"), "\r", ""), "\n")
649
+ let lines be split(replace(read_file(LOG_DIR + "/" + id + ".log"), "\r", ""), "\n")
650
650
  when length(lines) > n
651
651
  set lines to slice(lines, length(lines) - n, length(lines))
652
652
  give join(lines, "\n")