lampson 0.2.4 → 0.2.6
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.
- package/.env.example +1 -1
- package/README.md +58 -445
- package/chat.syn +147 -77
- package/hub.tpl.syn +18 -4
- package/lib/diff.syn +37 -2
- package/lib/provider.syn +7 -2
- package/lib/tools/common.syn +13 -0
- package/lib/workspaces.syn +70 -6
- package/package.json +1 -1
- package/public/css/chat.css +7 -0
- package/public/css/hub.css +4 -0
- package/public/css/panel.css +4 -0
- package/public/css/tokens.css +2 -2
- package/public/favicon.svg +13 -0
- package/public/hub.html +1 -0
- package/public/index.html +1 -0
- package/public/js/events.js +31 -3
- package/public/js/hub.js +9 -1
- package/public/js/panel.js +2 -0
- package/public/js/workspaces.js +11 -7
- package/web.syn +2 -1
package/chat.syn
CHANGED
|
@@ -79,6 +79,10 @@ task dim(s)
|
|
|
79
79
|
give c("2", s)
|
|
80
80
|
task yellow(s)
|
|
81
81
|
give c("33", s)
|
|
82
|
+
-- actividad de tools (▸, $): amarillo atenuado, para que el 33 pleno quede solo como "atención
|
|
83
|
+
-- requerida" (⚠ aprobaciones, avisos) — semáforo: actividad ≠ advertencia (2026-08-31)
|
|
84
|
+
task act(s)
|
|
85
|
+
give c("2;33", s)
|
|
82
86
|
task green(s)
|
|
83
87
|
give c("32", s)
|
|
84
88
|
task red(s)
|
|
@@ -276,7 +280,7 @@ task on_event(kind, data, tag)
|
|
|
276
280
|
set pending_call to desc
|
|
277
281
|
otherwise
|
|
278
282
|
set pending_call to ""
|
|
279
|
-
print(" " +
|
|
283
|
+
print(" " + act("▸ " + desc))
|
|
280
284
|
otherwise when kind == "tool_result"
|
|
281
285
|
let out be data["output"]
|
|
282
286
|
let name be data["call"]["name"]
|
|
@@ -284,7 +288,7 @@ task on_event(kind, data, tag)
|
|
|
284
288
|
let d be diff_of(name, out)
|
|
285
289
|
let path be when d != nothing then text(data["call"]["args"]["path"]) otherwise ""
|
|
286
290
|
set turn_outputs to append(turn_outputs, {"name": name, "args": data["call"]["args"], "output": out, "diff": d, "path": path})
|
|
287
|
-
let lead be when pending_call != "" then " " +
|
|
291
|
+
let lead be when pending_call != "" then " " + act("▸ " + pending_call) + " " otherwise " "
|
|
288
292
|
set pending_call to ""
|
|
289
293
|
when d != nothing
|
|
290
294
|
print_diff_with(lead, path, d)
|
|
@@ -329,9 +333,7 @@ let COMMANDS be [
|
|
|
329
333
|
["/ask", "", "permisos: PREGUNTAR antes de un comando peligroso (rm -rf, git push --force, sudo…)"],
|
|
330
334
|
["/yolo", "", "permisos: PERMITIR comandos peligrosos sin preguntar (los destructivos del sistema siguen bloqueados)"],
|
|
331
335
|
["/strict", "", "permisos: DENEGAR comandos peligrosos"],
|
|
332
|
-
["/model", "
|
|
333
|
-
["/provider", "[nombre] [modelo]", "cambiar de proveedor (deepseek, anthropic, openai, minimax…); sin args lista cuáles tienen key"],
|
|
334
|
-
["/setup", "", "elegir proveedor, modelo y pegar la API key (queda en lampson/.lampson/config.json, local)"],
|
|
336
|
+
["/model", "[proveedor] [modelo]", "elegir proveedor y modelo con flechas (la lista de modelos la da la API); pide la key solo si falta; queda guardado"],
|
|
335
337
|
["/paste", "", "adjuntar la imagen del portapapeles al próximo mensaje (copiá una captura y escribí /paste)"],
|
|
336
338
|
["/image", "<ruta>", "adjuntar una imagen (png/jpg/gif/webp) al próximo mensaje; /image sin ruta muestra o quita las pendientes"],
|
|
337
339
|
["/delete", "<id>", "borrar una sesión guardada, con confirmación (ver /sessions)"],
|
|
@@ -381,7 +383,7 @@ task complete_args(cmd, head, last)
|
|
|
381
383
|
give workspace_paths(last)
|
|
382
384
|
when cmd == "/resume" or cmd == "/delete"
|
|
383
385
|
give apply((s) => s["id"], session.list())
|
|
384
|
-
when cmd == "/provider"
|
|
386
|
+
when cmd == "/provider" or cmd == "/model" or cmd == "/setup"
|
|
385
387
|
when first
|
|
386
388
|
give apply((p) => p["name"], provider.providers())
|
|
387
389
|
give []
|
|
@@ -420,9 +422,8 @@ task complete_args(cmd, head, last)
|
|
|
420
422
|
give []
|
|
421
423
|
give apply((s) => s["id"], schedule.summary())
|
|
422
424
|
when cmd == "/agent"
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
give []
|
|
425
|
+
-- solo los perfiles elegibles por el usuario: `worker` es interno de delegate (agents.names lo filtra)
|
|
426
|
+
give agents.names()
|
|
426
427
|
give []
|
|
427
428
|
|
|
428
429
|
-- rutas del workspace que empiezan con el prefijo escrito (directorios con "/" final para seguir completando)
|
|
@@ -599,51 +600,140 @@ task skills_list()
|
|
|
599
600
|
-- ---------- onboarding ----------
|
|
600
601
|
-- Primera vez (ningún proveedor con key) o /setup: elegir proveedor, modelo y pegar la key. Va a
|
|
601
602
|
-- lampson/.lampson/config.json (local, gitignored) y vale para terminal y web. Sin TTY no pregunta.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
603
|
+
-- ---------- /model · /provider · /setup: UN solo flujo ----------
|
|
604
|
+
-- Proveedor → modelo (lista viva de la API del proveedor) → key solo si falta. Todo con menús ↑↓ cuando hay
|
|
605
|
+
-- TTY; sin TTY, listas numeradas + read_line. Siempre se GUARDA en lampson/.lampson/config.json (igual que
|
|
606
|
+
-- la web): ya no hay "solo esta sesión". Con args (`/model glm glm-5.3-flash`) aplica directo lo que vino y
|
|
607
|
+
-- pregunta solo lo que falte. cur_name/cur_model = lo activo, para marcarlo y ponerlo primero.
|
|
608
|
+
let MENU_MAX be 18
|
|
609
|
+
|
|
610
|
+
-- menú o lista numerada según haya TTY. Devuelve el índice elegido, o nothing si el usuario canceló.
|
|
611
|
+
task pick_from(question, labels)
|
|
610
612
|
flush()
|
|
611
|
-
let pick be ed.choose(" " +
|
|
612
|
-
let name be ""
|
|
613
|
+
let pick be ed.choose(" " + question, labels, COLOR)
|
|
613
614
|
when pick == nothing
|
|
614
|
-
|
|
615
|
+
give nothing
|
|
616
|
+
when pick != -1
|
|
617
|
+
give pick
|
|
618
|
+
print(" " + question + " (número)")
|
|
619
|
+
each e in enumerate(labels)
|
|
620
|
+
print(" " + pad(text(e["index"] + 1) + ".", 4) + e["item"])
|
|
621
|
+
let choice be read_line(" > ")
|
|
622
|
+
when choice == nothing or trim(choice) == ""
|
|
623
|
+
give nothing
|
|
624
|
+
let c be trim(choice)
|
|
625
|
+
when matches(c, "^[0-9]+$")
|
|
626
|
+
let i be floor(number(c)) - 1
|
|
627
|
+
when i >= 0 and i < length(labels)
|
|
628
|
+
give i
|
|
629
|
+
give nothing
|
|
630
|
+
-- también vale escribir el nombre (primera palabra de la etiqueta)
|
|
631
|
+
each e in enumerate(labels)
|
|
632
|
+
when lower(split(trim(e["item"]), " ")[0]) == lower(c)
|
|
633
|
+
give e["index"]
|
|
634
|
+
give nothing
|
|
635
|
+
|
|
636
|
+
task ask_provider(cur_name)
|
|
637
|
+
let list be provider.providers()
|
|
638
|
+
-- el actual primero, después el resto en orden
|
|
639
|
+
let ordered be where(list, (p) => p["name"] == cur_name) + where(list, (p) => p["name"] != cur_name)
|
|
640
|
+
let labels be []
|
|
641
|
+
each p in ordered
|
|
642
|
+
let flag be when p["name"] == "ollama" then "local, sin key" otherwise (when p["has_key"] then "● key" otherwise "")
|
|
643
|
+
set labels to append(labels, pad(p["name"], 12) + pad(p["model"], 26) + pad(flag, 16) + (when p["name"] == cur_name then "◀ actual" otherwise ""))
|
|
644
|
+
let i be pick_from(cyan("Proveedor:"), labels)
|
|
645
|
+
when i == nothing
|
|
646
|
+
give ""
|
|
647
|
+
give ordered[i]["name"]
|
|
648
|
+
|
|
649
|
+
-- modelos del proveedor según su API; si son muchos, un filtro antes del menú; siempre "otro…" para tipear
|
|
650
|
+
task ask_model(name, cur_name, cur_model)
|
|
651
|
+
let pcfg be provider.config_for(name, "")
|
|
652
|
+
let default be when name == cur_name and cur_model != "" then cur_model otherwise pcfg["model"]
|
|
653
|
+
let lm be provider.list_models(pcfg)
|
|
654
|
+
when lm["error"] != nothing
|
|
655
|
+
print(" " + dim("no pude listar los modelos de " + name + " (" + lm["error"] + ")"))
|
|
656
|
+
let typed be read_line(" modelo [" + default + "]: ")
|
|
657
|
+
give when typed == nothing or trim(typed) == "" then default otherwise trim(typed)
|
|
658
|
+
let ids be lm["models"]
|
|
659
|
+
when length(ids) > MENU_MAX
|
|
660
|
+
print(" " + dim(text(length(ids)) + " modelos en " + name + " · escribí parte del nombre para filtrar (Enter = los primeros " + text(MENU_MAX) + ")"))
|
|
661
|
+
let f be read_line(" filtro: ")
|
|
662
|
+
let ff be when f == nothing then "" otherwise lower(trim(f))
|
|
663
|
+
when ff != ""
|
|
664
|
+
set ids to where(ids, (m) => contains(lower(m), ff))
|
|
665
|
+
when length(ids) == 0
|
|
666
|
+
print(" " + dim("ningún modelo contiene «" + ff + "»"))
|
|
667
|
+
set ids to lm["models"]
|
|
668
|
+
when length(ids) > MENU_MAX
|
|
669
|
+
set ids to slice(ids, 0, MENU_MAX)
|
|
670
|
+
-- el default primero (si la API lo lista), después el resto; al final la opción de escribir
|
|
671
|
+
let ordered be where(ids, (m) => m == default) + where(ids, (m) => m != default)
|
|
672
|
+
let labels be apply((m) => m + (when m == default then " ◀ actual" otherwise ""), ordered)
|
|
673
|
+
set labels to append(labels, "otro… (escribir)")
|
|
674
|
+
let i be pick_from(cyan("Modelo de " + name + ":") + dim(" (según su API)"), labels)
|
|
675
|
+
when i == nothing
|
|
676
|
+
give ""
|
|
677
|
+
when i == length(labels) - 1
|
|
678
|
+
let typed be read_line(" modelo [" + default + "]: ")
|
|
679
|
+
give when typed == nothing or trim(typed) == "" then default otherwise trim(typed)
|
|
680
|
+
give ordered[i]
|
|
681
|
+
|
|
682
|
+
-- pide la key si el proveedor no tiene una guardada. Devuelve false si el usuario canceló.
|
|
683
|
+
task ensure_key(name)
|
|
684
|
+
when name == "ollama" or provider.has_key(name)
|
|
685
|
+
give true
|
|
686
|
+
print(" " + dim("la key se guarda en lampson/.lampson/config.json (solo en esta máquina)"))
|
|
687
|
+
let key be read_line(" API key de " + name + ": ")
|
|
688
|
+
when key == nothing
|
|
615
689
|
give false
|
|
616
|
-
|
|
617
|
-
print(" " +
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
690
|
+
when trim(key) == ""
|
|
691
|
+
print(" " + yellow("sin key: podés agregarla después con /model"))
|
|
692
|
+
give true
|
|
693
|
+
settings.set_key(name, trim(key))
|
|
694
|
+
give true
|
|
695
|
+
|
|
696
|
+
-- pre_name / pre_model: lo que vino por args ("" = preguntar). Devuelve true si guardó algo.
|
|
697
|
+
task setup_wizard(pre_name, pre_model, cur_name, cur_model)
|
|
698
|
+
print("")
|
|
699
|
+
let name be lower(trim(pre_name))
|
|
700
|
+
when name == ""
|
|
701
|
+
set name to ask_provider(cur_name)
|
|
702
|
+
when name == ""
|
|
623
703
|
print(" (sin cambios)")
|
|
624
704
|
give false
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
705
|
+
when not contains(provider.PRESETS, name)
|
|
706
|
+
print(" " + red("no conozco el proveedor " + name) + " · disponibles: " + join(sort_by(keys(provider.PRESETS), (x) => x), ", "))
|
|
707
|
+
give false
|
|
708
|
+
-- la key va antes que el modelo: sin key la API no lista los modelos
|
|
709
|
+
when not ensure_key(name)
|
|
710
|
+
print(" (sin cambios)")
|
|
711
|
+
give false
|
|
712
|
+
let model be trim(pre_model)
|
|
713
|
+
when model == ""
|
|
714
|
+
set model to ask_model(name, cur_name, cur_model)
|
|
715
|
+
when model == ""
|
|
716
|
+
print(" (sin cambios)")
|
|
632
717
|
give false
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
set model to
|
|
638
|
-
when name != "ollama"
|
|
639
|
-
print(" " + dim("la key se guarda en lampson/.lampson/config.json (solo en esta máquina); Enter para dejar la actual"))
|
|
640
|
-
let key be read_line(" API key de " + name + ": ")
|
|
641
|
-
when key != nothing and trim(key) != ""
|
|
642
|
-
settings.set_key(name, trim(key))
|
|
718
|
+
-- un "modelo" que empieza con / es un comando tipeado por error (o un stdin que se acabó): no se guarda
|
|
719
|
+
when starts_with(model, "/")
|
|
720
|
+
print(" (sin cambios)")
|
|
721
|
+
give false
|
|
722
|
+
set model to provider.normalize_model(name, model)
|
|
643
723
|
settings.set_default(name, model)
|
|
644
|
-
print(" " + green("
|
|
724
|
+
print(" " + green("✓ " + name + " · " + model + " · guardado") + dim(" (/model para cambiarlo; /model <proveedor> [modelo] va directo)"))
|
|
645
725
|
give true
|
|
646
726
|
|
|
727
|
+
-- `/model x y` · `/provider x y` · `/setup`: args → (proveedor, modelo). Un solo arg que no es proveedor =
|
|
728
|
+
-- modelo del proveedor actual.
|
|
729
|
+
task setup_from_args(rest, cur_name, cur_model)
|
|
730
|
+
let args be where(split(trim(rest), " "), (x) => x != "")
|
|
731
|
+
when length(args) == 0
|
|
732
|
+
give setup_wizard("", "", cur_name, cur_model)
|
|
733
|
+
when length(args) == 1 and not contains(provider.PRESETS, lower(args[0]))
|
|
734
|
+
give setup_wizard(cur_name, args[0], cur_name, cur_model)
|
|
735
|
+
give setup_wizard(args[0], when length(args) > 1 then args[1] otherwise "", cur_name, cur_model)
|
|
736
|
+
|
|
647
737
|
-- ---------- imágenes en el REPL ----------
|
|
648
738
|
-- Una terminal no puede pegar una imagen, así que se lee del portapapeles (/paste) o de un archivo
|
|
649
739
|
-- (/image ruta) con un script del sistema que devuelve "media_type|w|h|base64". Quedan pendientes
|
|
@@ -685,10 +775,14 @@ let cfg be provider.config()
|
|
|
685
775
|
when not provider.configured() and cfg["provider"] != "ollama"
|
|
686
776
|
print("")
|
|
687
777
|
print(" Bienvenido a Lampson. Todavía no hay ninguna API key configurada.")
|
|
688
|
-
when setup_wizard()
|
|
778
|
+
when setup_wizard("", "", cfg["provider"], cfg["model"])
|
|
689
779
|
set cfg to provider.config()
|
|
690
780
|
let env_info be agents.env_info(cfg)
|
|
691
781
|
let profile be lower(env("LAMPSON_AGENT", "build"))
|
|
782
|
+
-- `--agent worker` (o cualquier nombre desconocido): worker es interno de delegate, no un perfil de sesión
|
|
783
|
+
when not contains(agents.names(), profile)
|
|
784
|
+
print("agente '" + profile + "' no disponible · disponibles: " + join(agents.names(), ", ") + " · uso build")
|
|
785
|
+
set profile to "build"
|
|
692
786
|
task system_for(p)
|
|
693
787
|
give {"role": "system", "content": prompt.build(env_info, agents.addendum_for(p))}
|
|
694
788
|
task opts_for(p, mode)
|
|
@@ -925,41 +1019,17 @@ while running
|
|
|
925
1019
|
print(dim(" hay " + text(length(turn_outputs)) + " resultados en este turno: /out 1 … /out " + text(length(turn_outputs))))
|
|
926
1020
|
otherwise
|
|
927
1021
|
let o be turn_outputs[length(turn_outputs) - back]
|
|
928
|
-
print(" " +
|
|
1022
|
+
print(" " + act("▸ " + permission.describe_call(o["name"], o["args"])))
|
|
929
1023
|
print_full(o)
|
|
930
1024
|
otherwise when starts_with(input, "/trace")
|
|
931
1025
|
let n be trim(slice(input, 6, length(input)))
|
|
932
1026
|
let lines be when n == "" then 40 otherwise floor(number(n))
|
|
933
1027
|
print(dim(" " + trace.file_of(sid)))
|
|
934
1028
|
print(trace.tail(sid, lines))
|
|
935
|
-
otherwise when starts_with(input, "/model")
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
let lm be provider.list_models(cfg)
|
|
940
|
-
when lm["error"] != nothing
|
|
941
|
-
print(" " + dim("no pude listar los modelos de " + cfg["provider"] + ": " + lm["error"]))
|
|
942
|
-
otherwise
|
|
943
|
-
print(" disponibles en " + cfg["provider"] + ": " + join(lm["models"], ", "))
|
|
944
|
-
otherwise
|
|
945
|
-
set cfg["model"] to provider.normalize_model(cfg["provider"], want)
|
|
946
|
-
print("modelo=" + want + " (solo esta sesión; para fijarlo: LAMPSON_MODEL en lampson\\.env)")
|
|
947
|
-
otherwise when starts_with(input, "/provider")
|
|
948
|
-
let args be where(split(trim(slice(input, 9, length(input))), " "), (x) => x != "")
|
|
949
|
-
when length(args) == 0
|
|
950
|
-
print(" proveedor actual: " + cfg["provider"] + " · modelo " + cfg["model"])
|
|
951
|
-
each p in provider.providers()
|
|
952
|
-
print(" " + (when p["has_key"] then green("● ") otherwise dim("○ ")) + pad(p["name"], 12) + dim(p["model"] + (when p["has_key"] then "" otherwise " (sin key: LAMPSON_API_KEY_" + upper(p["name"]) + " en lampson\\.env)")))
|
|
953
|
-
print(" uso: /provider <nombre> [modelo]")
|
|
954
|
-
otherwise when not contains(provider.PRESETS, lower(args[0]))
|
|
955
|
-
print(" no conozco el proveedor " + args[0] + " · disponibles: " + join(sort_by(keys(provider.PRESETS), (x) => x), ", "))
|
|
956
|
-
otherwise
|
|
957
|
-
set cfg to provider.config_for(args[0], when length(args) > 1 then args[1] otherwise "")
|
|
958
|
-
when not cfg["has_key"]
|
|
959
|
-
print(" " + yellow("ojo: no hay key para " + cfg["provider"] + " — agregá LAMPSON_API_KEY_" + upper(cfg["provider"]) + "=… en lampson\\.env (se usará LAMPSON_API_KEY, que probablemente falle)"))
|
|
960
|
-
print(" proveedor=" + cfg["provider"] + " · modelo=" + cfg["model"] + " · " + cfg["wire"] + " → " + cfg["base_url"] + dim(" (solo esta sesión; para fijarlo: LAMPSON_PROVIDER en .env)"))
|
|
961
|
-
otherwise when input == "/setup"
|
|
962
|
-
when setup_wizard()
|
|
1029
|
+
otherwise when starts_with(input, "/model") or starts_with(input, "/provider") or starts_with(input, "/setup")
|
|
1030
|
+
-- un solo flujo (ver setup_wizard): proveedor → modelo (lista de la API) → key si falta; se guarda en config.json
|
|
1031
|
+
let rest be trim(replace_re(input, "^/(model|provider|setup)", ""))
|
|
1032
|
+
when setup_from_args(rest, cfg["provider"], cfg["model"])
|
|
963
1033
|
set cfg to provider.config()
|
|
964
1034
|
otherwise when input == "/paste"
|
|
965
1035
|
attach_image("clip", "")
|
|
@@ -1271,7 +1341,7 @@ while running
|
|
|
1271
1341
|
let want be trim(slice(input, 6, length(input)))
|
|
1272
1342
|
when want == ""
|
|
1273
1343
|
print("agente=" + profile + " · disponibles: " + join(agents.names(), ", "))
|
|
1274
|
-
otherwise when contains(agents.
|
|
1344
|
+
otherwise when contains(agents.names(), want)
|
|
1275
1345
|
set profile to want
|
|
1276
1346
|
set system_msg to system_for(profile)
|
|
1277
1347
|
set opts to opts_for(profile, mode)
|
|
@@ -1288,7 +1358,7 @@ while running
|
|
|
1288
1358
|
when cmd == ""
|
|
1289
1359
|
print(" uso: !<comando> p.ej. !git status · !npm test · !cat .env")
|
|
1290
1360
|
otherwise
|
|
1291
|
-
print(" " +
|
|
1361
|
+
print(" " + act("$ " + cmd))
|
|
1292
1362
|
flush()
|
|
1293
1363
|
let out be run_interactive(cmd)
|
|
1294
1364
|
set messages to append(messages, {"role": "user", "content": "[The user ran this command in the shell themselves]\n$ " + cmd + "\n" + tools.truncate(out, 6000)})
|
package/hub.tpl.syn
CHANGED
|
@@ -57,13 +57,21 @@ serve on 8080
|
|
|
57
57
|
-- la UI de un workspace: el mismo index.html; el frontend deduce /w/<slug> de la URL (BASE en js/core.js).
|
|
58
58
|
-- Sin barra final: el runtime normaliza "/w/x/" → "/w/x" (301), así que la ruta canónica es esta
|
|
59
59
|
route "GET /w/:slug"
|
|
60
|
+
-- entrar a un workspace apagado lo enciende (y levanta la pausa manual): si alguien abre la URL es para usarlo
|
|
61
|
+
let w be ws.get(params.slug)
|
|
62
|
+
when w != nothing and ws.health(w) == nothing
|
|
63
|
+
ws.start_ws(w)
|
|
64
|
+
ws.wait_ready(w, 8)
|
|
65
|
+
ws.summary()
|
|
60
66
|
give html(read_file("public/index.html"))
|
|
61
67
|
|
|
62
68
|
route "GET /api/hub" requires auth
|
|
63
69
|
give {"ok": true, "port": ws.HUB_PORT, "idle_hours": ws.idle_hours(), "home": ws.home()}
|
|
64
70
|
|
|
65
71
|
route "GET /api/workspaces" requires auth
|
|
66
|
-
|
|
72
|
+
-- la caché del blackboard (tick + acciones) salvo ?fresh=1: la UI lo pide tras encender/apagar
|
|
73
|
+
let fresh be contains(query, "fresh")
|
|
74
|
+
give {"workspaces": when fresh then ws.summary() otherwise ws.summary_cached(20), "idle_hours": ws.idle_hours()}
|
|
67
75
|
|
|
68
76
|
-- registrar una carpeta como workspace (y arrancarla)
|
|
69
77
|
route "POST /api/workspaces" requires auth
|
|
@@ -73,6 +81,7 @@ serve on 8080
|
|
|
73
81
|
let w be ws.ensure(text(b["path"]))
|
|
74
82
|
ws.start_ws(w)
|
|
75
83
|
ws.wait_ready(w, 8)
|
|
84
|
+
ws.summary()
|
|
76
85
|
give {"ok": true, "workspace": w, "url": "/w/" + w["slug"] + "/"}
|
|
77
86
|
recover err
|
|
78
87
|
give fail(400, text(err))
|
|
@@ -84,7 +93,9 @@ serve on 8080
|
|
|
84
93
|
when w == nothing
|
|
85
94
|
give not_found("no such workspace")
|
|
86
95
|
ws.start_ws(w)
|
|
87
|
-
|
|
96
|
+
let ok be ws.wait_ready(w, 8)
|
|
97
|
+
ws.summary()
|
|
98
|
+
give {"ok": ok}
|
|
88
99
|
|
|
89
100
|
route "POST /api/workspaces/stop" requires auth
|
|
90
101
|
expect body {slug: text}
|
|
@@ -92,13 +103,15 @@ serve on 8080
|
|
|
92
103
|
let w be ws.get(text(b["slug"]))
|
|
93
104
|
when w == nothing
|
|
94
105
|
give not_found("no such workspace")
|
|
95
|
-
give {"ok": ws.
|
|
106
|
+
give {"ok": ws.pause_ws(w)}
|
|
96
107
|
|
|
97
108
|
route "POST /api/workspaces/remove" requires auth
|
|
98
109
|
expect body {slug: text}
|
|
99
110
|
let b be json of request
|
|
100
111
|
try
|
|
101
|
-
|
|
112
|
+
let res be ws.remove(text(b["slug"]))
|
|
113
|
+
ws.summary()
|
|
114
|
+
give {"ok": true, "result": res}
|
|
102
115
|
recover err
|
|
103
116
|
give fail(400, text(err))
|
|
104
117
|
|
|
@@ -113,6 +126,7 @@ serve on 8080
|
|
|
113
126
|
give fail(400, "policy must be auto | always | off")
|
|
114
127
|
set w["policy"] to text(b["policy"])
|
|
115
128
|
ws.put(w)
|
|
129
|
+
ws.summary()
|
|
116
130
|
give {"ok": true}
|
|
117
131
|
|
|
118
132
|
-- diálogo nativo del SO (PC); nothing → la UI usa el explorador propio
|
package/lib/diff.syn
CHANGED
|
@@ -8,6 +8,10 @@ use "./ui.syn" as ui
|
|
|
8
8
|
|
|
9
9
|
let ESC be decode(bytes("1b", "hex"))
|
|
10
10
|
let LIMIT be 80
|
|
11
|
+
-- corridas largas de +/- se colapsan: HEAD líneas, "⋮ ±N líneas más", TAIL líneas (un rewrite de 200
|
|
12
|
+
-- líneas pintaba 200 rojas + 200 verdes; el diff completo sigue en la UI web, que recibe old/new crudos)
|
|
13
|
+
let HEAD be 4
|
|
14
|
+
let TAIL be 2
|
|
11
15
|
|
|
12
16
|
-- línea de diff con fondo (verde/rojo) rellenada hasta el ancho útil, cortada si se pasa
|
|
13
17
|
task row(color, kind, num, body, width)
|
|
@@ -40,6 +44,26 @@ task lpad(s, n)
|
|
|
40
44
|
set out to " " + out
|
|
41
45
|
give out
|
|
42
46
|
|
|
47
|
+
-- vuelca una corrida de líneas +/- ya formateadas: entera si es corta, colapsada si supera HEAD+TAIL+1
|
|
48
|
+
task flush_run(lines, rows, op, color, width)
|
|
49
|
+
when length(rows) <= HEAD + TAIL + 1
|
|
50
|
+
each r in rows
|
|
51
|
+
set lines to append(lines, r)
|
|
52
|
+
give lines
|
|
53
|
+
let i be 0
|
|
54
|
+
while i < HEAD
|
|
55
|
+
set lines to append(lines, rows[i])
|
|
56
|
+
set i to i + 1
|
|
57
|
+
let hidden be length(rows) - HEAD - TAIL
|
|
58
|
+
let sign be when op == "+" then "+" otherwise "−"
|
|
59
|
+
let code be when op == "+" then "32;2" otherwise "31;2"
|
|
60
|
+
set lines to append(lines, sgr(color, code, lpad("", width) + " ⋮ " + sign + text(hidden) + " líneas más"))
|
|
61
|
+
set i to length(rows) - TAIL
|
|
62
|
+
while i < length(rows)
|
|
63
|
+
set lines to append(lines, rows[i])
|
|
64
|
+
set i to i + 1
|
|
65
|
+
give lines
|
|
66
|
+
|
|
43
67
|
-- LCS clásico: devuelve la lista de ops {"op": " "|"-"|"+", "a": idx_old, "b": idx_new}
|
|
44
68
|
task lcs_ops(a, b)
|
|
45
69
|
let n be length(a)
|
|
@@ -135,6 +159,9 @@ export task diff(old, new, ctx, color)
|
|
|
135
159
|
set idx to idx + 1
|
|
136
160
|
set idx to 0
|
|
137
161
|
let skipping be false
|
|
162
|
+
-- corrida de +/- consecutivos: se junta y se vuelca colapsada (flush_run) al cambiar de tipo
|
|
163
|
+
let run_op be ""
|
|
164
|
+
let run_rows be []
|
|
138
165
|
each o in ops
|
|
139
166
|
let near be false
|
|
140
167
|
let w be idx - ctx
|
|
@@ -142,6 +169,10 @@ export task diff(old, new, ctx, color)
|
|
|
142
169
|
when w >= 0 and w < length(ops) and ops[w]["op"] != " "
|
|
143
170
|
set near to true
|
|
144
171
|
set w to w + 1
|
|
172
|
+
when o["op"] != run_op and run_op != ""
|
|
173
|
+
set lines to flush_run(lines, run_rows, run_op, color, width)
|
|
174
|
+
set run_op to ""
|
|
175
|
+
set run_rows to []
|
|
145
176
|
when o["op"] == " "
|
|
146
177
|
when near
|
|
147
178
|
set lines to append(lines, row(color, " ", text(pre + o["b"] + 1), mid_b[o["b"]], width))
|
|
@@ -150,12 +181,16 @@ export task diff(old, new, ctx, color)
|
|
|
150
181
|
set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
|
|
151
182
|
set skipping to true
|
|
152
183
|
otherwise when o["op"] == "-"
|
|
153
|
-
set
|
|
184
|
+
set run_op to "-"
|
|
185
|
+
set run_rows to append(run_rows, row(color, "-", text(pre + o["a"] + 1), mid_a[o["a"]], width))
|
|
154
186
|
set skipping to false
|
|
155
187
|
otherwise
|
|
156
|
-
set
|
|
188
|
+
set run_op to "+"
|
|
189
|
+
set run_rows to append(run_rows, row(color, "+", text(pre + o["b"] + 1), mid_b[o["b"]], width))
|
|
157
190
|
set skipping to false
|
|
158
191
|
set idx to idx + 1
|
|
192
|
+
when run_op != ""
|
|
193
|
+
set lines to flush_run(lines, run_rows, run_op, color, width)
|
|
159
194
|
-- contexto posterior (del sufijo común)
|
|
160
195
|
let after_start be length(b) - suf
|
|
161
196
|
let upto be when after_start + ctx < length(b) then after_start + ctx otherwise length(b)
|
package/lib/provider.syn
CHANGED
|
@@ -36,7 +36,9 @@ export let PRESETS be {
|
|
|
36
36
|
"openrouter": {"wire": "openai", "base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-chat"},
|
|
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
|
-
"minimax": {"wire": "anthropic", "base_url": "https://api.minimax.io/anthropic/v1", "model": "MiniMax-M3"}
|
|
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
42
|
}
|
|
41
43
|
|
|
42
44
|
-- Keys: una por proveedor en .env como LAMPSON_API_KEY_<PROVIDER> (LAMPSON_API_KEY_DEEPSEEK, _ANTHROPIC…);
|
|
@@ -95,7 +97,7 @@ export task providers()
|
|
|
95
97
|
-- Nombres de modelo: DeepSeek, OpenAI, Anthropic, Groq, Kimi, Grok y OpenRouter publican ids en minúsculas
|
|
96
98
|
-- y la API rechaza otra capitalización ("deepseek-v4-pro" sí, "DeepSeek-V4-Pro" → 400 invalid_request_error,
|
|
97
99
|
-- visto 2026-08-27 con un nombre tipeado en /setup). MiniMax ("MiniMax-M3") y Ollama (tags) se dejan tal cual.
|
|
98
|
-
let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter"]
|
|
100
|
+
let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter", "glm"]
|
|
99
101
|
|
|
100
102
|
export task normalize_model(name, model)
|
|
101
103
|
let m be trim(text(model))
|
|
@@ -255,6 +257,9 @@ export task supports_vision(cfg)
|
|
|
255
257
|
give contains(m, "vision") or starts_with(m, "grok-4")
|
|
256
258
|
when p == "kimi"
|
|
257
259
|
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-")
|
|
258
263
|
give false
|
|
259
264
|
|
|
260
265
|
-- Modelo sin visión: la imagen se reemplaza por una nota que el modelo puede explicarle al usuario
|
package/lib/tools/common.syn
CHANGED
|
@@ -11,6 +11,13 @@ export let ROOT be "workspace"
|
|
|
11
11
|
export let MAX_OUTPUT be 30000
|
|
12
12
|
export let IGNORED_DIRS be [".git", "node_modules", ".synsema", ".lampson", "workspace", "target", "dist", "build", "coverage", "__pycache__", ".venv", ".next", ".netlify", ".cache", ".turbo"]
|
|
13
13
|
|
|
14
|
+
-- true si algún segmento del path es ".." (no cuenta "foo..bar")
|
|
15
|
+
task has_dotdot(p)
|
|
16
|
+
each part in split(p, "/")
|
|
17
|
+
when part == ".."
|
|
18
|
+
give true
|
|
19
|
+
give false
|
|
20
|
+
|
|
14
21
|
-- path del modelo → path real bajo workspace/
|
|
15
22
|
export task ws(path)
|
|
16
23
|
let p be when path == nothing then "." otherwise replace_text(text(path), "\\", "/")
|
|
@@ -22,6 +29,12 @@ export task ws(path)
|
|
|
22
29
|
set p to slice(p, 1, length(p))
|
|
23
30
|
otherwise
|
|
24
31
|
set stripping to false
|
|
32
|
+
-- escape del workspace (C:\… o ..): mensaje instructivo ANTES del runtime. Sin esto el modelo
|
|
33
|
+
-- recibía "Capability not granted: file_write(…)" (con ..) u "os error 123" (con C:\…, que parece
|
|
34
|
+
-- un error de sintaxis de la ruta) y quemaba reintentos probando variantes (visto 2026-08-31:
|
|
35
|
+
-- quiso guardar una "memoria global" fuera del workspace, como el install global de skills).
|
|
36
|
+
when contains(split(p, "/")[0], ":") or has_dotdot(p)
|
|
37
|
+
raise("path escapes the workspace: \"" + text(path) + "\" — file tools only reach the mounted workspace; use paths relative to its root (e.g. \"src/app.ts\"). Do not retry with other path variants. To keep notes across sessions use the memory tool (the harness stores them per project, outside the repo); for anything else outside the workspace, explain what you need and ask the user.")
|
|
25
38
|
when p == "." or p == "" or p == ROOT
|
|
26
39
|
give ROOT
|
|
27
40
|
when starts_with(p, ROOT + "/")
|