lampson 0.2.4 → 0.2.5
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 +139 -73
- package/hub.tpl.syn +18 -4
- package/lib/provider.syn +7 -2
- 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
|
@@ -329,9 +329,7 @@ let COMMANDS be [
|
|
|
329
329
|
["/ask", "", "permisos: PREGUNTAR antes de un comando peligroso (rm -rf, git push --force, sudo…)"],
|
|
330
330
|
["/yolo", "", "permisos: PERMITIR comandos peligrosos sin preguntar (los destructivos del sistema siguen bloqueados)"],
|
|
331
331
|
["/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)"],
|
|
332
|
+
["/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
333
|
["/paste", "", "adjuntar la imagen del portapapeles al próximo mensaje (copiá una captura y escribí /paste)"],
|
|
336
334
|
["/image", "<ruta>", "adjuntar una imagen (png/jpg/gif/webp) al próximo mensaje; /image sin ruta muestra o quita las pendientes"],
|
|
337
335
|
["/delete", "<id>", "borrar una sesión guardada, con confirmación (ver /sessions)"],
|
|
@@ -381,7 +379,7 @@ task complete_args(cmd, head, last)
|
|
|
381
379
|
give workspace_paths(last)
|
|
382
380
|
when cmd == "/resume" or cmd == "/delete"
|
|
383
381
|
give apply((s) => s["id"], session.list())
|
|
384
|
-
when cmd == "/provider"
|
|
382
|
+
when cmd == "/provider" or cmd == "/model" or cmd == "/setup"
|
|
385
383
|
when first
|
|
386
384
|
give apply((p) => p["name"], provider.providers())
|
|
387
385
|
give []
|
|
@@ -420,9 +418,8 @@ task complete_args(cmd, head, last)
|
|
|
420
418
|
give []
|
|
421
419
|
give apply((s) => s["id"], schedule.summary())
|
|
422
420
|
when cmd == "/agent"
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
give []
|
|
421
|
+
-- solo los perfiles elegibles por el usuario: `worker` es interno de delegate (agents.names lo filtra)
|
|
422
|
+
give agents.names()
|
|
426
423
|
give []
|
|
427
424
|
|
|
428
425
|
-- rutas del workspace que empiezan con el prefijo escrito (directorios con "/" final para seguir completando)
|
|
@@ -599,51 +596,140 @@ task skills_list()
|
|
|
599
596
|
-- ---------- onboarding ----------
|
|
600
597
|
-- Primera vez (ningún proveedor con key) o /setup: elegir proveedor, modelo y pegar la key. Va a
|
|
601
598
|
-- lampson/.lampson/config.json (local, gitignored) y vale para terminal y web. Sin TTY no pregunta.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
599
|
+
-- ---------- /model · /provider · /setup: UN solo flujo ----------
|
|
600
|
+
-- Proveedor → modelo (lista viva de la API del proveedor) → key solo si falta. Todo con menús ↑↓ cuando hay
|
|
601
|
+
-- TTY; sin TTY, listas numeradas + read_line. Siempre se GUARDA en lampson/.lampson/config.json (igual que
|
|
602
|
+
-- la web): ya no hay "solo esta sesión". Con args (`/model glm glm-5.3-flash`) aplica directo lo que vino y
|
|
603
|
+
-- pregunta solo lo que falte. cur_name/cur_model = lo activo, para marcarlo y ponerlo primero.
|
|
604
|
+
let MENU_MAX be 18
|
|
605
|
+
|
|
606
|
+
-- menú o lista numerada según haya TTY. Devuelve el índice elegido, o nothing si el usuario canceló.
|
|
607
|
+
task pick_from(question, labels)
|
|
610
608
|
flush()
|
|
611
|
-
let pick be ed.choose(" " +
|
|
612
|
-
let name be ""
|
|
609
|
+
let pick be ed.choose(" " + question, labels, COLOR)
|
|
613
610
|
when pick == nothing
|
|
614
|
-
|
|
611
|
+
give nothing
|
|
612
|
+
when pick != -1
|
|
613
|
+
give pick
|
|
614
|
+
print(" " + question + " (número)")
|
|
615
|
+
each e in enumerate(labels)
|
|
616
|
+
print(" " + pad(text(e["index"] + 1) + ".", 4) + e["item"])
|
|
617
|
+
let choice be read_line(" > ")
|
|
618
|
+
when choice == nothing or trim(choice) == ""
|
|
619
|
+
give nothing
|
|
620
|
+
let c be trim(choice)
|
|
621
|
+
when matches(c, "^[0-9]+$")
|
|
622
|
+
let i be floor(number(c)) - 1
|
|
623
|
+
when i >= 0 and i < length(labels)
|
|
624
|
+
give i
|
|
625
|
+
give nothing
|
|
626
|
+
-- también vale escribir el nombre (primera palabra de la etiqueta)
|
|
627
|
+
each e in enumerate(labels)
|
|
628
|
+
when lower(split(trim(e["item"]), " ")[0]) == lower(c)
|
|
629
|
+
give e["index"]
|
|
630
|
+
give nothing
|
|
631
|
+
|
|
632
|
+
task ask_provider(cur_name)
|
|
633
|
+
let list be provider.providers()
|
|
634
|
+
-- el actual primero, después el resto en orden
|
|
635
|
+
let ordered be where(list, (p) => p["name"] == cur_name) + where(list, (p) => p["name"] != cur_name)
|
|
636
|
+
let labels be []
|
|
637
|
+
each p in ordered
|
|
638
|
+
let flag be when p["name"] == "ollama" then "local, sin key" otherwise (when p["has_key"] then "● key" otherwise "")
|
|
639
|
+
set labels to append(labels, pad(p["name"], 12) + pad(p["model"], 26) + pad(flag, 16) + (when p["name"] == cur_name then "◀ actual" otherwise ""))
|
|
640
|
+
let i be pick_from(cyan("Proveedor:"), labels)
|
|
641
|
+
when i == nothing
|
|
642
|
+
give ""
|
|
643
|
+
give ordered[i]["name"]
|
|
644
|
+
|
|
645
|
+
-- modelos del proveedor según su API; si son muchos, un filtro antes del menú; siempre "otro…" para tipear
|
|
646
|
+
task ask_model(name, cur_name, cur_model)
|
|
647
|
+
let pcfg be provider.config_for(name, "")
|
|
648
|
+
let default be when name == cur_name and cur_model != "" then cur_model otherwise pcfg["model"]
|
|
649
|
+
let lm be provider.list_models(pcfg)
|
|
650
|
+
when lm["error"] != nothing
|
|
651
|
+
print(" " + dim("no pude listar los modelos de " + name + " (" + lm["error"] + ")"))
|
|
652
|
+
let typed be read_line(" modelo [" + default + "]: ")
|
|
653
|
+
give when typed == nothing or trim(typed) == "" then default otherwise trim(typed)
|
|
654
|
+
let ids be lm["models"]
|
|
655
|
+
when length(ids) > MENU_MAX
|
|
656
|
+
print(" " + dim(text(length(ids)) + " modelos en " + name + " · escribí parte del nombre para filtrar (Enter = los primeros " + text(MENU_MAX) + ")"))
|
|
657
|
+
let f be read_line(" filtro: ")
|
|
658
|
+
let ff be when f == nothing then "" otherwise lower(trim(f))
|
|
659
|
+
when ff != ""
|
|
660
|
+
set ids to where(ids, (m) => contains(lower(m), ff))
|
|
661
|
+
when length(ids) == 0
|
|
662
|
+
print(" " + dim("ningún modelo contiene «" + ff + "»"))
|
|
663
|
+
set ids to lm["models"]
|
|
664
|
+
when length(ids) > MENU_MAX
|
|
665
|
+
set ids to slice(ids, 0, MENU_MAX)
|
|
666
|
+
-- el default primero (si la API lo lista), después el resto; al final la opción de escribir
|
|
667
|
+
let ordered be where(ids, (m) => m == default) + where(ids, (m) => m != default)
|
|
668
|
+
let labels be apply((m) => m + (when m == default then " ◀ actual" otherwise ""), ordered)
|
|
669
|
+
set labels to append(labels, "otro… (escribir)")
|
|
670
|
+
let i be pick_from(cyan("Modelo de " + name + ":") + dim(" (según su API)"), labels)
|
|
671
|
+
when i == nothing
|
|
672
|
+
give ""
|
|
673
|
+
when i == length(labels) - 1
|
|
674
|
+
let typed be read_line(" modelo [" + default + "]: ")
|
|
675
|
+
give when typed == nothing or trim(typed) == "" then default otherwise trim(typed)
|
|
676
|
+
give ordered[i]
|
|
677
|
+
|
|
678
|
+
-- pide la key si el proveedor no tiene una guardada. Devuelve false si el usuario canceló.
|
|
679
|
+
task ensure_key(name)
|
|
680
|
+
when name == "ollama" or provider.has_key(name)
|
|
681
|
+
give true
|
|
682
|
+
print(" " + dim("la key se guarda en lampson/.lampson/config.json (solo en esta máquina)"))
|
|
683
|
+
let key be read_line(" API key de " + name + ": ")
|
|
684
|
+
when key == nothing
|
|
615
685
|
give false
|
|
616
|
-
|
|
617
|
-
print(" " +
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
686
|
+
when trim(key) == ""
|
|
687
|
+
print(" " + yellow("sin key: podés agregarla después con /model"))
|
|
688
|
+
give true
|
|
689
|
+
settings.set_key(name, trim(key))
|
|
690
|
+
give true
|
|
691
|
+
|
|
692
|
+
-- pre_name / pre_model: lo que vino por args ("" = preguntar). Devuelve true si guardó algo.
|
|
693
|
+
task setup_wizard(pre_name, pre_model, cur_name, cur_model)
|
|
694
|
+
print("")
|
|
695
|
+
let name be lower(trim(pre_name))
|
|
696
|
+
when name == ""
|
|
697
|
+
set name to ask_provider(cur_name)
|
|
698
|
+
when name == ""
|
|
623
699
|
print(" (sin cambios)")
|
|
624
700
|
give false
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
701
|
+
when not contains(provider.PRESETS, name)
|
|
702
|
+
print(" " + red("no conozco el proveedor " + name) + " · disponibles: " + join(sort_by(keys(provider.PRESETS), (x) => x), ", "))
|
|
703
|
+
give false
|
|
704
|
+
-- la key va antes que el modelo: sin key la API no lista los modelos
|
|
705
|
+
when not ensure_key(name)
|
|
706
|
+
print(" (sin cambios)")
|
|
707
|
+
give false
|
|
708
|
+
let model be trim(pre_model)
|
|
709
|
+
when model == ""
|
|
710
|
+
set model to ask_model(name, cur_name, cur_model)
|
|
711
|
+
when model == ""
|
|
712
|
+
print(" (sin cambios)")
|
|
632
713
|
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))
|
|
714
|
+
-- un "modelo" que empieza con / es un comando tipeado por error (o un stdin que se acabó): no se guarda
|
|
715
|
+
when starts_with(model, "/")
|
|
716
|
+
print(" (sin cambios)")
|
|
717
|
+
give false
|
|
718
|
+
set model to provider.normalize_model(name, model)
|
|
643
719
|
settings.set_default(name, model)
|
|
644
|
-
print(" " + green("
|
|
720
|
+
print(" " + green("✓ " + name + " · " + model + " · guardado") + dim(" (/model para cambiarlo; /model <proveedor> [modelo] va directo)"))
|
|
645
721
|
give true
|
|
646
722
|
|
|
723
|
+
-- `/model x y` · `/provider x y` · `/setup`: args → (proveedor, modelo). Un solo arg que no es proveedor =
|
|
724
|
+
-- modelo del proveedor actual.
|
|
725
|
+
task setup_from_args(rest, cur_name, cur_model)
|
|
726
|
+
let args be where(split(trim(rest), " "), (x) => x != "")
|
|
727
|
+
when length(args) == 0
|
|
728
|
+
give setup_wizard("", "", cur_name, cur_model)
|
|
729
|
+
when length(args) == 1 and not contains(provider.PRESETS, lower(args[0]))
|
|
730
|
+
give setup_wizard(cur_name, args[0], cur_name, cur_model)
|
|
731
|
+
give setup_wizard(args[0], when length(args) > 1 then args[1] otherwise "", cur_name, cur_model)
|
|
732
|
+
|
|
647
733
|
-- ---------- imágenes en el REPL ----------
|
|
648
734
|
-- Una terminal no puede pegar una imagen, así que se lee del portapapeles (/paste) o de un archivo
|
|
649
735
|
-- (/image ruta) con un script del sistema que devuelve "media_type|w|h|base64". Quedan pendientes
|
|
@@ -685,10 +771,14 @@ let cfg be provider.config()
|
|
|
685
771
|
when not provider.configured() and cfg["provider"] != "ollama"
|
|
686
772
|
print("")
|
|
687
773
|
print(" Bienvenido a Lampson. Todavía no hay ninguna API key configurada.")
|
|
688
|
-
when setup_wizard()
|
|
774
|
+
when setup_wizard("", "", cfg["provider"], cfg["model"])
|
|
689
775
|
set cfg to provider.config()
|
|
690
776
|
let env_info be agents.env_info(cfg)
|
|
691
777
|
let profile be lower(env("LAMPSON_AGENT", "build"))
|
|
778
|
+
-- `--agent worker` (o cualquier nombre desconocido): worker es interno de delegate, no un perfil de sesión
|
|
779
|
+
when not contains(agents.names(), profile)
|
|
780
|
+
print("agente '" + profile + "' no disponible · disponibles: " + join(agents.names(), ", ") + " · uso build")
|
|
781
|
+
set profile to "build"
|
|
692
782
|
task system_for(p)
|
|
693
783
|
give {"role": "system", "content": prompt.build(env_info, agents.addendum_for(p))}
|
|
694
784
|
task opts_for(p, mode)
|
|
@@ -932,34 +1022,10 @@ while running
|
|
|
932
1022
|
let lines be when n == "" then 40 otherwise floor(number(n))
|
|
933
1023
|
print(dim(" " + trace.file_of(sid)))
|
|
934
1024
|
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()
|
|
1025
|
+
otherwise when starts_with(input, "/model") or starts_with(input, "/provider") or starts_with(input, "/setup")
|
|
1026
|
+
-- un solo flujo (ver setup_wizard): proveedor → modelo (lista de la API) → key si falta; se guarda en config.json
|
|
1027
|
+
let rest be trim(replace_re(input, "^/(model|provider|setup)", ""))
|
|
1028
|
+
when setup_from_args(rest, cfg["provider"], cfg["model"])
|
|
963
1029
|
set cfg to provider.config()
|
|
964
1030
|
otherwise when input == "/paste"
|
|
965
1031
|
attach_image("clip", "")
|
|
@@ -1271,7 +1337,7 @@ while running
|
|
|
1271
1337
|
let want be trim(slice(input, 6, length(input)))
|
|
1272
1338
|
when want == ""
|
|
1273
1339
|
print("agente=" + profile + " · disponibles: " + join(agents.names(), ", "))
|
|
1274
|
-
otherwise when contains(agents.
|
|
1340
|
+
otherwise when contains(agents.names(), want)
|
|
1275
1341
|
set profile to want
|
|
1276
1342
|
set system_msg to system_for(profile)
|
|
1277
1343
|
set opts to opts_for(profile, mode)
|
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/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/workspaces.syn
CHANGED
|
@@ -380,6 +380,10 @@ export task start_ws(w)
|
|
|
380
380
|
require file.read("web.syn")
|
|
381
381
|
require file.read("chat.syn")
|
|
382
382
|
require file.read(".env")
|
|
383
|
+
-- encender levanta la pausa manual
|
|
384
|
+
when contains(w, "paused") and w["paused"] == true
|
|
385
|
+
set w["paused"] to false
|
|
386
|
+
put(w)
|
|
383
387
|
let d be prepare(w)
|
|
384
388
|
let h be home()
|
|
385
389
|
let sep be when is_win() then "\\" otherwise "/"
|
|
@@ -396,13 +400,21 @@ export task health(w)
|
|
|
396
400
|
require net("127.0.0.1")
|
|
397
401
|
require time
|
|
398
402
|
try
|
|
399
|
-
let r be
|
|
403
|
+
let r be http("GET", "http://127.0.0.1:" + text(w["port"]) + "/w/" + w["slug"] + "/api/health", {}, {}, "", 1.5)
|
|
400
404
|
when r["ok"]
|
|
401
405
|
give json_decode(body of r)
|
|
402
406
|
give nothing
|
|
403
407
|
recover err
|
|
404
408
|
give nothing
|
|
405
409
|
|
|
410
|
+
-- salud de TODOS a la vez. En Windows con el firewall en modo stealth, conectar a un puerto cerrado de 127.0.0.1
|
|
411
|
+
-- no se rechaza al instante: el SYN queda sin respuesta ~2 s. Secuencial era 2 s × workspaces (6 s con tres
|
|
412
|
+
-- apagados); en paralelo es 2 s en total, y el resultado se comparte en el blackboard para que la lista sea inmediata.
|
|
413
|
+
export task health_all(list)
|
|
414
|
+
require net("127.0.0.1")
|
|
415
|
+
require time
|
|
416
|
+
give parallel_map(health, list, 8)
|
|
417
|
+
|
|
406
418
|
-- esperar hasta que responda (arranque)
|
|
407
419
|
export task wait_ready(w, secs)
|
|
408
420
|
require net("127.0.0.1")
|
|
@@ -414,6 +426,25 @@ export task wait_ready(w, secs)
|
|
|
414
426
|
sleep(0.4)
|
|
415
427
|
give false
|
|
416
428
|
|
|
429
|
+
-- «■ apagar»: marca la pausa manual en el registro y para el proceso; el supervisor no lo vuelve a levantar
|
|
430
|
+
export task pause_ws(w)
|
|
431
|
+
require exec
|
|
432
|
+
require time
|
|
433
|
+
require env("LAMPSON_*")
|
|
434
|
+
require env("OS")
|
|
435
|
+
require net("127.0.0.1")
|
|
436
|
+
require file(".lampson")
|
|
437
|
+
require file(".lampson/*")
|
|
438
|
+
set w["paused"] to true
|
|
439
|
+
put(w)
|
|
440
|
+
stop_ws(w)
|
|
441
|
+
-- esperar a que deje de responder (matar tarda unos segundos) y dejar la caché al día
|
|
442
|
+
let deadline be now() + 6
|
|
443
|
+
while now() < deadline and health(w) != nothing
|
|
444
|
+
sleep(0.4)
|
|
445
|
+
summary()
|
|
446
|
+
give true
|
|
447
|
+
|
|
417
448
|
export task stop_ws(w)
|
|
418
449
|
require exec
|
|
419
450
|
require time
|
|
@@ -490,6 +521,11 @@ export task should_live(w, hc)
|
|
|
490
521
|
require file.read(".lampson/*")
|
|
491
522
|
require env("LAMPSON_*")
|
|
492
523
|
require time
|
|
524
|
+
-- apagado a mano («■ apagar» en el hub): gana sobre la política y sobre las tareas programadas hasta que
|
|
525
|
+
-- alguien lo encienda (botón ▶, o `lampson` en esa carpeta). Antes el tick lo revivía a los 15 s y el botón
|
|
526
|
+
-- parecía no hacer nada.
|
|
527
|
+
when contains(w, "paused") and w["paused"] == true
|
|
528
|
+
give false
|
|
493
529
|
let pol be when contains(w, "policy") then w["policy"] otherwise "auto"
|
|
494
530
|
when pol == "always"
|
|
495
531
|
give true
|
|
@@ -528,8 +564,11 @@ export task tick()
|
|
|
528
564
|
restart_hub_later()
|
|
529
565
|
let started be []
|
|
530
566
|
let stopped be []
|
|
531
|
-
|
|
532
|
-
|
|
567
|
+
let list be all()
|
|
568
|
+
let hcs be health_all(list)
|
|
569
|
+
each e in enumerate(list)
|
|
570
|
+
let w be e["item"]
|
|
571
|
+
let hc be hcs[e["index"]]
|
|
533
572
|
let want be should_live(w, hc)
|
|
534
573
|
when want and hc == nothing
|
|
535
574
|
start_ws(w)
|
|
@@ -537,6 +576,7 @@ export task tick()
|
|
|
537
576
|
otherwise when not want and hc != nothing
|
|
538
577
|
stop_ws(w)
|
|
539
578
|
set stopped to append(stopped, w["slug"])
|
|
579
|
+
summary()
|
|
540
580
|
give {"started": started, "stopped": stopped}
|
|
541
581
|
|
|
542
582
|
-- resumen para la UI del hub
|
|
@@ -549,11 +589,35 @@ export task summary()
|
|
|
549
589
|
require file(".lampson")
|
|
550
590
|
require file(".lampson/*")
|
|
551
591
|
let out be []
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
592
|
+
let list be all()
|
|
593
|
+
let hcs be health_all(list)
|
|
594
|
+
each e in enumerate(list)
|
|
595
|
+
let w be e["item"]
|
|
596
|
+
let hc be hcs[e["index"]]
|
|
597
|
+
set out to append(out, {"slug": w["slug"], "name": w["name"], "path": w["path"], "port": w["port"], "policy": when contains(w, "policy") then w["policy"] otherwise "auto", "created": w["created"], "last_used": when hc != nothing and contains(hc, "last_used") and hc["last_used"] != nothing then hc["last_used"] otherwise w["last_used"], "alive": hc != nothing, "paused": contains(w, "paused") and w["paused"] == true, "schedules_on": when hc != nothing and contains(hc, "schedules_on") then hc["schedules_on"] otherwise schedules_on_disk(w), "url": "/w/" + w["slug"]})
|
|
598
|
+
share out as "hub:summary"
|
|
599
|
+
share now() as "hub:summary_at"
|
|
555
600
|
give out
|
|
556
601
|
|
|
602
|
+
-- la lista que ve la UI: la última calculada (tick cada 15 s, y cada start/stop la refresca) si tiene menos de
|
|
603
|
+
-- max_age segundos; si no, se calcula ahora. GET /api/workspaces?fresh=1 fuerza el cálculo.
|
|
604
|
+
export task summary_cached(max_age)
|
|
605
|
+
require exec
|
|
606
|
+
require time
|
|
607
|
+
require env("LAMPSON_*")
|
|
608
|
+
require env("OS")
|
|
609
|
+
require net("127.0.0.1")
|
|
610
|
+
require file(".lampson")
|
|
611
|
+
require file(".lampson/*")
|
|
612
|
+
try
|
|
613
|
+
observe "hub:summary_at" as at
|
|
614
|
+
observe "hub:summary" as cached
|
|
615
|
+
when at != nothing and cached != nothing and now() - at < max_age
|
|
616
|
+
give cached
|
|
617
|
+
recover err
|
|
618
|
+
give summary()
|
|
619
|
+
give summary()
|
|
620
|
+
|
|
557
621
|
-- ---------- explorador de carpetas ----------
|
|
558
622
|
-- diálogo nativo del SO (PC con escritorio). Devuelve la ruta elegida, "" si canceló, nothing si no hay diálogo.
|
|
559
623
|
export task pick_folder()
|
package/package.json
CHANGED
package/public/css/chat.css
CHANGED
|
@@ -54,3 +54,10 @@
|
|
|
54
54
|
.empty .try { cursor:pointer; color:var(--accent); }
|
|
55
55
|
.empty .try:hover { text-decoration:underline; text-underline-offset:.18em; }
|
|
56
56
|
@media (max-width:1400px) { .empty .grid { grid-template-columns:repeat(2, minmax(0,1fr)); } }
|
|
57
|
+
|
|
58
|
+
/* workspace apagado (events.js): un aviso que se vea, no una línea más del log */
|
|
59
|
+
.msg.wsoff{border:1px solid var(--rubric);border-left-width:4px;background:var(--paper-2);padding:12px 16px;border-radius:var(--r);font:400 14.5px/1.55 var(--serif);color:var(--ink);opacity:1}
|
|
60
|
+
.msg.wsoff a{font-family:var(--mono);font-size:13px;margin-left:.2em;color:var(--accent);text-decoration:none}
|
|
61
|
+
.msg.wsoff a:hover{text-decoration:underline}
|
|
62
|
+
.msg.wsoff .wsst{color:var(--ink-3);font-family:var(--mono);font-size:12px;margin-left:.6em}
|
|
63
|
+
.msg.wson{border-left:4px solid var(--ok, #8fc28f);padding-left:12px}
|
package/public/css/hub.css
CHANGED
|
@@ -6,6 +6,10 @@ body.hub #stage { display:flex; align-items:center; padding:20px 40px; }
|
|
|
6
6
|
.card.ws { display:block; text-decoration:none; color:inherit; cursor:pointer; transition:border-color .12s; }
|
|
7
7
|
.card.ws:hover { border-color:var(--ink-3); }
|
|
8
8
|
.card.ws.on { border-color:var(--accent); }
|
|
9
|
+
/* estado a un vistazo: verde vivo · rojo apagado · ámbar mientras arranca */
|
|
10
|
+
.card.ws.on h3 { color:var(--ok); }
|
|
11
|
+
.card.ws.off h3 { color:var(--rubric); }
|
|
12
|
+
.card.ws.busy h3 { color:var(--amber); }
|
|
9
13
|
.card.ws.new { border-style:dashed; }
|
|
10
14
|
.card.ws .nm { font:600 16px/1.3 var(--serif); color:var(--ink); margin:0 0 4px; }
|
|
11
15
|
.card.ws .pth { font:400 11px/1.4 var(--mono); color:var(--ink-3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin:0 0 6px; }
|
package/public/css/panel.css
CHANGED
|
@@ -58,6 +58,10 @@
|
|
|
58
58
|
.panel .li:hover { background:var(--paper-2); }
|
|
59
59
|
.panel .li.sel { background:var(--paper-2); box-shadow:inset 2px 0 0 var(--accent); }
|
|
60
60
|
.panel .li .dot { color:var(--ink-3); margin-top:1px; }
|
|
61
|
+
.panel .li .dot.on { color:var(--ok); }
|
|
62
|
+
.panel .li .dot.off { color:var(--rubric); }
|
|
63
|
+
.panel .st.on { color:var(--ok); font-weight:600; }
|
|
64
|
+
.panel .st.off { color:var(--rubric); font-weight:600; }
|
|
61
65
|
.panel .li.on .dot { color:var(--accent); }
|
|
62
66
|
.panel .li.bad .dot { color:var(--rubric); }
|
|
63
67
|
.panel .li.new .nm { color:var(--accent); font-weight:400; }
|
package/public/css/tokens.css
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
--ink:#e9e8df; --ink-2:#9b9c90; --ink-3:#848678;
|
|
20
20
|
--rule:#2b2c27; --rule-2:#3c3d36;
|
|
21
21
|
--accent:#8fb4dc; --accent-bg:#1b2836;
|
|
22
|
-
--rubric:#d98670; --str:#a8bd84; --amber:#d9b26f;
|
|
22
|
+
--rubric:#d98670; --str:#a8bd84; --amber:#d9b26f; --ok:#8fc28f;
|
|
23
23
|
--sel:#33465b; --term-blue:#4f86d1;
|
|
24
24
|
--serif:"Plex Serif",Georgia,"Times New Roman",serif;
|
|
25
25
|
--mono:"Plex Mono",ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
--ink:#1a1b18; --ink-2:#56584f; --ink-3:#6a6c61;
|
|
31
31
|
--rule:#e4e3da; --rule-2:#cfcec2;
|
|
32
32
|
--accent:#21578c; --accent-bg:#e9eef4;
|
|
33
|
-
--rubric:#99372a; --str:#4a6b3f; --amber:#8a6a1f;
|
|
33
|
+
--rubric:#99372a; --str:#4a6b3f; --amber:#8a6a1f; --ok:#3d7a3d;
|
|
34
34
|
--sel:#cfe0f0; --term-blue:#21578c;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect width="64" height="64" rx="10" fill="#131412"/>
|
|
3
|
+
<circle cx="32" cy="27" r="11" fill="#d9c26a"/>
|
|
4
|
+
<g stroke="#d9c26a" stroke-width="3" stroke-linecap="round">
|
|
5
|
+
<line x1="32" y1="6" x2="32" y2="10"/>
|
|
6
|
+
<line x1="14" y1="13" x2="17" y2="16"/>
|
|
7
|
+
<line x1="50" y1="13" x2="47" y2="16"/>
|
|
8
|
+
<line x1="9" y1="27" x2="13" y2="27"/>
|
|
9
|
+
<line x1="55" y1="27" x2="51" y2="27"/>
|
|
10
|
+
</g>
|
|
11
|
+
<rect x="26" y="40" width="12" height="7" rx="1.5" fill="#e9e8df"/>
|
|
12
|
+
<rect x="27.5" y="49" width="9" height="3" rx="1.5" fill="#9b9c90"/>
|
|
13
|
+
</svg>
|
package/public/hub.html
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<html lang="es">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
5
6
|
<title>Lampson · workspaces</title>
|
|
6
7
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
8
|
<link rel="stylesheet" href="/css/tokens.css">
|
package/public/index.html
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<html lang="es">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
5
6
|
<title>Lampson</title>
|
|
6
7
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
8
|
<!-- estilos: tokens (fuentes, paleta, base) → layout (grid, cabecera, paneles, chat, composer) → sidebar → chat → panel (el modal) -->
|