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
package/lib/git.syn CHANGED
@@ -21,12 +21,12 @@ export task status()
21
21
  let branch be ""
22
22
  let changes be {}
23
23
  let counts be {"modified": 0, "added": 0, "deleted": 0, "untracked": 0}
24
- each line in split(replace_text(out, "\r", ""), "\n")
24
+ each line in split(replace(out, "\r", ""), "\n")
25
25
  when starts_with(line, "## ")
26
26
  set branch to split(split(slice(line, 3, length(line)), "...")[0], " ")[0]
27
27
  otherwise when length(line) > 3
28
28
  let xy be slice(line, 0, 2)
29
- let path be replace_text(trim(slice(line, 3, length(line))), "\"", "")
29
+ let path be replace(trim(slice(line, 3, length(line))), "\"", "")
30
30
  when contains(path, " -> ")
31
31
  set path to split(path, " -> ")[1]
32
32
  let code be trim(xy)
package/lib/judge.syn ADDED
@@ -0,0 +1,78 @@
1
+ -- lib/judge.syn — el juez de decisión de Lampson: un modelo System One que contesta preguntas tipadas con
2
+ -- probabilidades (el bloque `judge` de Synsema: whether / choose / rate), no con texto
3
+ --
4
+ -- Es una credencial/configuración de Lampson, como el proveedor del LLM — no de una tool. Hoy lo usan:
5
+ -- * computer_use: la acción `act` (elige el próximo control de una lista cerrada) y page_select (elige la opción
6
+ -- de un desplegable cuando el texto no coincide) — lib/computer.syn + lib/computer_judge.syn
7
+ -- y cualquier parte de Lampson que necesite una decisión calibrada y barata puede usarlo con `require judge` y
8
+ -- judge.on() como compuerta (clasificar, rutear, validar, decidir si algo aplica…).
9
+ --
10
+ -- Backends (se elige en ⚙ → Juez de decisión, /judge en la terminal):
11
+ -- jev TypeSafe Jev, en la nube: rápido (~0,3 s por decisión), multilingüe, necesita API key (keys.typesafe)
12
+ -- laya Laya local (ModernBERT, ~840 MB en disco): sin red ni costo, ~1-2 s por decisión en CPU; medido
13
+ -- 2026-09-28: con interfaces en español el checkpoint `english` se equivoca con confianza alta
14
+ -- off sin juez
15
+ -- El runtime lee el proveedor del ENTORNO al arrancar el proceso (settings.judge_env): cambiarlo reinicia el
16
+ -- workspace (web) o pide salir y volver a entrar (terminal).
17
+
18
+ use "./settings.syn" as settings
19
+
20
+ export let MODES be ["off", "jev", "laya"]
21
+
22
+ export task norm_mode(v)
23
+ let t be lower(trim(text(when v == nothing then "" otherwise v)))
24
+ when t == "jev" or t == "typesafe"
25
+ give "jev"
26
+ when t == "laya" or t == "local"
27
+ give "laya"
28
+ give "off"
29
+
30
+ -- el modo configurado (config.json / .env), esté vivo o no en este proceso
31
+ export task mode()
32
+ require file.read(".lampson")
33
+ require file.read(".lampson/*")
34
+ require env("LAMPSON_*")
35
+ give norm_mode(settings.judge_setting()["mode"])
36
+
37
+ -- encendido en la configuración Y con un proveedor vivo en este proceso: la compuerta de quien lo use
38
+ export task on()
39
+ require file.read(".lampson")
40
+ require file.read(".lampson/*")
41
+ require env("LAMPSON_*")
42
+ when mode() == "off"
43
+ give false
44
+ give judge_available()
45
+
46
+ -- {mode, model_setting, has_key, live, provider_model, restart, from_env}: lo que ven la UI y /judge
47
+ export task status()
48
+ require file.read(".lampson")
49
+ require file.read(".lampson/*")
50
+ require env("LAMPSON_*")
51
+ let js be settings.judge_setting()
52
+ let m be norm_mode(js["mode"])
53
+ let live be judge_available()
54
+ let pm be judge_model()
55
+ -- el proceso corre otro proveedor que el configurado (o ninguno): hace falta reiniciar
56
+ let restart be false
57
+ when m != "off" and not live
58
+ set restart to true
59
+ when m == "laya" and pm != nothing and not starts_with(text(pm), "laya:")
60
+ set restart to true
61
+ when m == "jev" and pm != nothing and starts_with(text(pm), "laya:")
62
+ set restart to true
63
+ give {"mode": m, "model_setting": text(js["model"]), "has_key": settings.raw_key("typesafe") != "", "live": live, "provider_model": when pm == nothing then "" otherwise text(pm), "restart": restart, "from_env": js["from_env"]}
64
+
65
+ -- guardar el modo (y opcionalmente el modelo / la key de TypeSafe) → {ok, error}
66
+ export task configure(m, model, key)
67
+ require file(".lampson")
68
+ require file(".lampson/*")
69
+ require env("LAMPSON_*")
70
+ let nm be lower(trim(text(m)))
71
+ when not contains(MODES, nm) and nm != "typesafe" and nm != "local"
72
+ give {"ok": false, "error": "judge: off, jev or laya"}
73
+ set nm to norm_mode(nm)
74
+ when key != nothing
75
+ when trim(text(key)) != ""
76
+ settings.set_key("typesafe", trim(text(key)))
77
+ settings.set_judge(nm, model)
78
+ give {"ok": true, "error": nothing}
package/lib/line.syn CHANGED
@@ -37,7 +37,7 @@ task starts(s, p)
37
37
  task candidates(buf, ctx)
38
38
  when not starts(buf, "/") or contains(buf, "\n")
39
39
  give {"kind": "", "items": [], "token": ""}
40
- let sp be capture(buf, "^(/\S*)\s+(.*)$")
40
+ let sp be regex_capture(buf, "^(/\S*)\s+(.*)$")
41
41
  when sp == nothing
42
42
  -- comando a medias: recientes primero cuando es "/" pelado
43
43
  let items be []
@@ -208,7 +208,7 @@ export task read(prompt, color, ctx)
208
208
  set result to nothing
209
209
  set done to true
210
210
  otherwise when ev["type"] == "paste"
211
- set st to insert(st, replace_text(ev["text"], "\r", ""))
211
+ set st to insert(st, replace(ev["text"], "\r", ""))
212
212
  set st to refresh_menu(st, ctx)
213
213
  otherwise when ev["type"] == "resize"
214
214
  ui.set_cols(ev["cols"])
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).
@@ -47,7 +64,7 @@ let REPEAT_HARD be 8
47
64
  -- seguidas sin ninguna acción (edit/write/bash/process/delegate/mcp). Visto 2026-08-28 con deepseek-v4-pro:
48
65
  -- 39 read/ls/find seguidos, 0 ediciones, 419k tokens, dos veces, con las reglas de prompt ignoradas.
49
66
  -- A la mitad del tope el resultado lleva un aviso; al tope, read/ls/find/grep se rechazan hasta que actúe.
50
- let READ_ONLY_TOOLS be ["read", "ls", "find", "grep"]
67
+ let READ_ONLY_TOOLS be ["read", "ls", "find", "grep", "fetch"]
51
68
  let ACTION_TOOLS be ["edit", "write", "bash", "process", "delegate"]
52
69
 
53
70
  export task explore_cap()
@@ -67,7 +84,7 @@ export task explore_verdict(streak, name, cap)
67
84
  when n >= cap
68
85
  give {"streak": n, "note": "", "refuse": true}
69
86
  when n == floor(cap / 2)
70
- give {"streak": n, "note": "\n\n[harness] " + text(n) + " read-only calls in a row without changing anything. You likely know enough: edit/write now, or run the relevant command. After " + text(cap) + " read-only calls in a row, read/ls/find/grep are refused until you act. If the codebase is genuinely too big, delegate ONE focused question to an `explore` sub-agent instead of reading everything yourself.", "refuse": false}
87
+ give {"streak": n, "note": "\n\n[harness] " + text(n) + " read-only calls in a row without changing anything. You likely know enough: edit/write now, or run the relevant command. After " + text(cap) + " read-only calls in a row, read/ls/find/grep/fetch are refused until you act. If the codebase is genuinely too big, delegate ONE focused question to an `explore` sub-agent instead of reading everything yourself.", "refuse": false}
71
88
  give {"streak": n, "note": "", "refuse": false}
72
89
 
73
90
  -- JSON canónico (claves ordenadas en profundidad): reordenar propiedades no engaña al detector
@@ -98,7 +115,8 @@ task safe_id(id, step)
98
115
  export task spill(name, id, out)
99
116
  require file(".lampson")
100
117
  require file(".lampson/*")
101
- when name == "read" or length(out) <= SPILL_CAP
118
+ -- read y fetch se recortan solas (fetch: cabeza+cola con el texto completo ya en .lampson/spill)
119
+ when name == "read" or name == "fetch" or length(out) <= SPILL_CAP
102
120
  give out
103
121
  -- el informe de los subagentes es el entregable: no se manda a disco (hasta 60k)
104
122
  when name == "delegate" and length(out) <= SPILL_CAP * 6
@@ -106,7 +124,7 @@ export task spill(name, id, out)
106
124
  let path be SPILL_DIR + "/" + id + ".txt"
107
125
  write_file(path, out)
108
126
  let half be floor(SPILL_CAP / 2)
109
- 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))
110
128
 
111
129
  -- recibo de una línea para un tool result viejo
112
130
  export task receipt(m)
@@ -115,13 +133,13 @@ export task receipt(m)
115
133
  give out
116
134
  let status be "ok"
117
135
  when starts_with(out, "ERROR") or starts_with(out, "DENIED")
118
- 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)
119
137
  otherwise
120
- let codes be find_all(out, "\\[exit code [0-9]+\\]")
138
+ let codes be regex_find_all(out, "\\[exit code [0-9]+\\]")
121
139
  when length(codes) > 0
122
140
  let last be codes[length(codes) - 1]
123
141
  set status to slice(last, 1, length(last) - 1)
124
- let saved be find_all(out, "Full result saved to [^ ]+")
142
+ let saved be regex_find_all(out, "Full result saved to [^ ]+")
125
143
  let where_full be when length(saved) > 0 then " · " + saved[0] otherwise ""
126
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]"
127
145
 
@@ -323,6 +341,8 @@ export task run_turn(cfg, messages, opts, on_event)
323
341
  let max_steps be opts["max_steps"]
324
342
  let budget be opts["budget_tokens"]
325
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"
326
346
  -- compaction preventiva
327
347
  when estimate_tokens(msgs) > opts["compact_at_tokens"]
328
348
  emit(on_event, "compact", {"before": estimate_tokens(msgs)}, tag)
@@ -375,6 +395,11 @@ export task run_turn(cfg, messages, opts, on_event)
375
395
  emit(on_event, "usage", {"step": steps, "usage": r["usage"], "total": usage}, tag)
376
396
  let clean_text be strip_raw_tool_calls(r["text"])
377
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"]
378
403
  set msgs to append(msgs, assistant)
379
404
  when clean_text != ""
380
405
  emit(on_event, "assistant", clean_text, tag)
@@ -394,7 +419,7 @@ export task run_turn(cfg, messages, opts, on_event)
394
419
  set out to "ERROR: you already called " + tc["name"] + " with these exact arguments " + text(repeats) + " times in a row. The result will not change; try a different approach or report the problem."
395
420
  emit(on_event, "tool_denied", {"call": tc, "reason": "repeated call (" + text(repeats) + "x)"}, tag)
396
421
  otherwise when ex["refuse"]
397
- set out to "ERROR: exploration cap reached — " + text(explore_streak) + " read-only calls in a row (read/ls/find/grep) without a single change. Reading more will not help. Do one of: (1) edit/write the files you already read; (2) run a command (bash/process) that moves the task; (3) delegate ONE focused question to an `explore` sub-agent; (4) tell the user what you need. read/ls/find/grep are refused until you do."
422
+ set out to "ERROR: exploration cap reached — " + text(explore_streak) + " read-only calls in a row (read/ls/find/grep) without a single change. Reading more will not help. Do one of: (1) edit/write the files you already read; (2) run a command (bash/process) that moves the task; (3) delegate ONE focused question to an `explore` sub-agent; (4) tell the user what you need. read/ls/find/grep/fetch are refused until you do."
398
423
  emit(on_event, "tool_denied", {"call": tc, "reason": "exploration cap (" + text(explore_streak) + " read-only calls without acting)"}, tag)
399
424
  otherwise when contains(READ_ONLY_TOOLS, tc["name"]) and reads_total >= turn_cap
400
425
  set out to "ERROR: this turn already made " + text(reads_total) + " read-only calls (limit " + text(turn_cap) + "). Everything you read is in your context. Act on it now (edit/write/run), delegate ONE focused question to an `explore` sub-agent, or report to the user."
@@ -421,6 +446,14 @@ export task run_turn(cfg, messages, opts, on_event)
421
446
  set errors to errors + 1
422
447
  emit(on_event, "tool_result", {"call": tc, "output": out}, tag)
423
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)
424
457
  when errors >= MAX_ERRORS_PER_TURN
425
458
  set msgs to append(msgs, {"role": "user", "content": "[harness] Too many tool errors this turn. Stop calling tools and report what happened."})
426
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)
@@ -9,6 +9,9 @@
9
9
  --
10
10
  -- Modos (LAMPSON_PERMISSION): "ask" (default) | "yolo" (dangerous → allow) | "strict" (dangerous → deny)
11
11
 
12
+ use "./tools/url.syn" as u
13
+ use "./computer.syn" as computer
14
+
12
15
  -- Tier 1: nunca. Coincidencia por substring, case-insensitive.
13
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")
14
17
  export let HARDLINE be [
@@ -69,9 +72,30 @@ export task evaluate(name, args, mode)
69
72
  give {"decision": "deny", "reason": "strict mode (dangerous: " + danger + ")"}
70
73
  give {"decision": "ask", "reason": "dangerous pattern: " + danger}
71
74
  give {"decision": "allow", "reason": "command"}
75
+ when name == "fetch"
76
+ -- política de hosts de url.syn (hermes url_safety): secretos en la URL y metadata de la nube se
77
+ -- deniegan SIEMPRE (incluso en yolo); hosts privados/loopback (el dev server del usuario) piden;
78
+ -- lo público se permite. Una URL inválida se permite: la tool devuelve el error explicado.
79
+ let furl be when contains(args, "url") then text(args["url"]) otherwise ""
80
+ let why be u.sensitive(furl)
81
+ when why != nothing
82
+ give {"decision": "deny", "reason": "the URL carries " + why + " — secrets never travel in URLs"}
83
+ let fp be u.parse(furl)
84
+ when not fp["ok"]
85
+ give {"decision": "allow", "reason": "invalid URL (the tool explains)"}
86
+ let cls be u.host_class(fp["host"])
87
+ when cls == "blocked"
88
+ give {"decision": "deny", "reason": "cloud metadata endpoint " + fp["host"]}
89
+ when cls == "private"
90
+ when mode == "yolo"
91
+ give {"decision": "allow", "reason": "yolo mode (private host " + fp["host"] + ")"}
92
+ when mode == "strict"
93
+ give {"decision": "deny", "reason": "strict mode (private host " + fp["host"] + ")"}
94
+ give {"decision": "ask", "reason": "fetches a private/loopback host (" + fp["origin"] + ")"}
95
+ give {"decision": "allow", "reason": "public URL"}
72
96
  when name == "write" or name == "edit"
73
97
  -- el scope file("./*") ya impide salir del workspace; aquí solo miramos secretos obvios
74
- 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 ""
75
99
  let parts be split(p, "/")
76
100
  let base be parts[length(parts) - 1]
77
101
  when is_secret_env(base)
@@ -130,11 +154,27 @@ export task evaluate(name, args, mode)
130
154
  when mode == "strict"
131
155
  give {"decision": "deny", "reason": "strict mode (installs a third-party skill)"}
132
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"}
133
173
  give {"decision": "allow", "reason": "read-only tool"}
134
174
 
135
175
  -- Resumen legible de una tool call, para mostrar al humano antes de aprobar / en el log.
136
176
  task one_line(s, max)
137
- let t be replace_text(replace_text(s, "\r", ""), "\n", " ⏎ ")
177
+ let t be replace(replace(s, "\r", ""), "\n", " ⏎ ")
138
178
  when length(t) > max
139
179
  give slice(t, 0, max) + "…"
140
180
  give t
@@ -142,6 +182,8 @@ task one_line(s, max)
142
182
  export task describe_call(name, args)
143
183
  when starts_with(name, "mcp_") or starts_with(name, "plugin_")
144
184
  give name + " " + one_line(json_encode(args), 160)
185
+ when name == "computer_use"
186
+ give computer.describe(args)
145
187
  when name == "lsp" and contains(args, "op") and text(args["op"]) == "add"
146
188
  give "lsp add " + (when contains(args, "server") then text(args["server"]) otherwise "?") + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
147
189
  when name == "lsp"
@@ -167,6 +209,9 @@ export task describe_call(name, args)
167
209
  give "skill install " + text(args["source"]) + " --skill " + text(args["name"]) + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
168
210
  when name == "bash"
169
211
  give "$ " + one_line(when contains(args, "command") then text(args["command"]) otherwise "", 160)
212
+ when name == "fetch"
213
+ let ffmt be arg(args, "format", "markdown")
214
+ give "fetch " + one_line(arg(args, "url", "?"), 140) + (when ffmt != "markdown" then " (" + ffmt + ")" otherwise "")
170
215
  -- lectura/búsqueda: como lo escribiría un humano en la shell
171
216
  when name == "read"
172
217
  let rng be when contains(args, "offset") or contains(args, "limit") then " (" + (when contains(args, "offset") then "desde " + text(floor(number(text(args["offset"])))) otherwise "") + (when contains(args, "limit") then " " + text(floor(number(text(args["limit"])))) + " líneas" otherwise "") + ")" otherwise ""
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
 
@@ -53,13 +61,14 @@ Operate like a careful senior engineer: precise, honest, and economical with wor
53
61
 
54
62
  # Tools
55
63
  - read (offset/limit), ls, find (glob), grep (regex): use them instead of bash with cat/ls/find/grep — line-numbered and cheaper. lsp (symbols/definition/references/hover): the file's structure without reading it, and exact navigation when grep is ambiguous — prefer lsp symbols plus a ranged read over reading a big file whole. edit: targeted replacement. write: create or fully replace.
64
+ - fetch (url, format, max_chars): web pages and HTTP APIs as Markdown — docs, READMEs, issues, changelogs, JSON. Use it instead of bash with curl/wget for anything you will read: it asks the site for Markdown first and strips scripts, styles and navigation, so a page costs a tenth of its HTML. A long page comes back head+tail with the full text saved to a file: read (offset/limit) or grep that file, never fetch the same URL again. Cite the URL when you use its content. Use bash+curl only for POST, custom headers or downloads.
56
65
  - bash: shell commands from the workspace root. State does not persist between calls (cd resets) — chain with &&. Check the [exit code: N] marker on every result (it is the last line) and investigate failures before moving on. Never pipe a build or test through head/tail: the output is already truncated for you (the full text is saved to a file whose path you get) and the pipe hides the real exit code. Never run a server or watcher here.
57
66
  - process: start/logs/stop long-running commands (dev servers, watchers). Their new log lines arrive automatically with every later tool result — do not sleep or poll. Stop what you started when the task is done.
58
67
  - delegate: sub-agents with a fresh context (explore, plan, review, worker); several tasks in one call run in parallel, background=true returns at once and the report arrives later as a message. Give a highly detailed, self-contained brief, say whether it should write code or only research, how to verify, and exactly what to return. Its report is a self-report — verify what matters before telling the user it is done. Its output is not visible to the user: summarise it.
59
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).
60
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.
61
70
  - todo: your task list (whole-list replacement, one in_progress at a time; see Method step 1).
62
- - 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}
63
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.
64
73
 
65
74
  # Tone