lampson 0.1.0
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 +29 -0
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/bin/lampson.js +81 -0
- package/chat.syn +799 -0
- package/lamps/example-hello/lamp.json +16 -0
- package/lamps/example-hello/lamp.syn +19 -0
- package/lampson.cmd +4 -0
- package/lampson.ps1 +88 -0
- package/lampson.sh +42 -0
- package/lib/agents.syn +471 -0
- package/lib/git.syn +58 -0
- package/lib/lamps.syn +386 -0
- package/lib/loop.syn +455 -0
- package/lib/lsp.syn +503 -0
- package/lib/mcp.syn +403 -0
- package/lib/permission.syn +154 -0
- package/lib/prompt.syn +75 -0
- package/lib/provider.syn +522 -0
- package/lib/session.syn +111 -0
- package/lib/settings.syn +70 -0
- package/lib/skills.syn +179 -0
- package/lib/tools/bash.syn +105 -0
- package/lib/tools/common.sh +49 -0
- package/lib/tools/common.syn +91 -0
- package/lib/tools/edit.syn +32 -0
- package/lib/tools/find.syn +35 -0
- package/lib/tools/grep.syn +31 -0
- package/lib/tools/img.ps1 +36 -0
- package/lib/tools/img.sh +22 -0
- package/lib/tools/ls.syn +18 -0
- package/lib/tools/memo.syn +148 -0
- package/lib/tools/proc.sh +42 -0
- package/lib/tools/proc.syn +314 -0
- package/lib/tools/process.syn +46 -0
- package/lib/tools/read.syn +25 -0
- package/lib/tools/skill.syn +14 -0
- package/lib/tools/todo.syn +97 -0
- package/lib/tools/write.syn +22 -0
- package/lib/tools.syn +198 -0
- package/lib/trace.syn +116 -0
- package/lib/tree.syn +59 -0
- package/lib/update.syn +57 -0
- package/package.json +40 -0
- package/public/fonts/plex-mono-400-latin-ext.woff2 +0 -0
- package/public/fonts/plex-mono-400-latin.woff2 +0 -0
- package/public/fonts/plex-mono-600-latin-ext.woff2 +0 -0
- package/public/fonts/plex-mono-600-latin.woff2 +0 -0
- package/public/fonts/plex-serif-400-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-400-latin.woff2 +0 -0
- package/public/fonts/plex-serif-400i-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-400i-latin.woff2 +0 -0
- package/public/fonts/plex-serif-600-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-600-latin.woff2 +0 -0
- package/public/index.html +1268 -0
- package/public/vendor/xterm-addon-fit.js +2 -0
- package/public/vendor/xterm.css +218 -0
- package/public/vendor/xterm.js +2 -0
- package/skills/debugging/SKILL.md +33 -0
- package/skills/lampson/SKILL.md +117 -0
- package/skills/synsema/SKILL.md +75 -0
- package/web.syn +468 -0
package/web.syn
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
-- web.syn — Lampson por URL (chat web con SSE)
|
|
2
|
+
--
|
|
3
|
+
-- synsema serve web.syn → http://127.0.0.1:8080
|
|
4
|
+
--
|
|
5
|
+
-- API:
|
|
6
|
+
-- GET / UI (public/index.html)
|
|
7
|
+
-- GET /api/sessions lista de sesiones
|
|
8
|
+
-- GET /api/sessions/:id historial de una sesión
|
|
9
|
+
-- POST /api/chat {session?, message, agent?} → SSE con eventos del loop y "done" al final
|
|
10
|
+
--
|
|
11
|
+
-- Permisos bajo web: el modo (ask|yolo|strict) viene en cada POST /api/chat (selector de la UI; default
|
|
12
|
+
-- LAMPSON_PERMISSION). En "ask", una tool peligrosa emite el evento SSE `approval_request` y el loop
|
|
13
|
+
-- espera (polling de state_*) hasta que la UI responda por POST /api/approve o venza el timeout → deniega.
|
|
14
|
+
-- Nunca se auto-aprueba en silencio.
|
|
15
|
+
--
|
|
16
|
+
-- MIGAS serve: secret() y config() se resuelven DENTRO del handler (globals redactados por request);
|
|
17
|
+
-- las tasks deben definirse ANTES de `serve on`; `send` solo parsea dentro de `stream`, así que
|
|
18
|
+
-- el emisor de eventos se define dentro del bloque stream.
|
|
19
|
+
|
|
20
|
+
intent: "Web chat for a coding agent: tools from an allow-list, least-privilege per tool, SSE progress"
|
|
21
|
+
|
|
22
|
+
require serve(8080)
|
|
23
|
+
require net
|
|
24
|
+
require time
|
|
25
|
+
require exec
|
|
26
|
+
require env("LAMPSON_*")
|
|
27
|
+
require env("OS")
|
|
28
|
+
require secret("LAMPSON_*")
|
|
29
|
+
require file("workspace")
|
|
30
|
+
require file("workspace/*")
|
|
31
|
+
require file.read("skills")
|
|
32
|
+
require file.read("skills/*")
|
|
33
|
+
require file.read("lamps")
|
|
34
|
+
require file.read("lamps/*")
|
|
35
|
+
require file("memory")
|
|
36
|
+
require file("memory/*")
|
|
37
|
+
require file(".lampson")
|
|
38
|
+
require file(".lampson/*")
|
|
39
|
+
|
|
40
|
+
use "./lib/provider.syn" as provider
|
|
41
|
+
use "./lib/tools.syn" as tools
|
|
42
|
+
use "./lib/prompt.syn" as prompt
|
|
43
|
+
use "./lib/loop.syn" as loop
|
|
44
|
+
use "./lib/session.syn" as session
|
|
45
|
+
use "./lib/agents.syn" as agents
|
|
46
|
+
use "./lib/tree.syn" as tree
|
|
47
|
+
use "./lib/git.syn" as git
|
|
48
|
+
use "./lib/tools/proc.syn" as proc
|
|
49
|
+
use "./lib/tools/memo.syn" as memo
|
|
50
|
+
use "./lib/update.syn" as update
|
|
51
|
+
use "./lib/settings.syn" as settings
|
|
52
|
+
use "./lib/trace.syn" as trace
|
|
53
|
+
use "./lib/mcp.syn" as mcp
|
|
54
|
+
use "./lib/lamps.syn" as lamps
|
|
55
|
+
use "./lib/lsp.syn" as lsp
|
|
56
|
+
use "./lib/tools/todo.syn" as todo
|
|
57
|
+
|
|
58
|
+
let APPROVAL_TIMEOUT be 180
|
|
59
|
+
|
|
60
|
+
-- Quién puede usar la API (chat con tools, terminal, matar procesos): `serve` escucha en 0.0.0.0, así que
|
|
61
|
+
-- sin esto cualquiera en la LAN tendría una shell en tu proyecto. Política: SOLO loopback. Opt-in para
|
|
62
|
+
-- clientes remotos: LAMPSON_WEB_TOKEN en .env + header `Authorization: Bearer <token>` (la UI web no lo
|
|
63
|
+
-- manda; es para scripts). `auth with` corre ANTES del upgrade WebSocket, así que cubre /api/term.
|
|
64
|
+
task check_client(token, request)
|
|
65
|
+
let ip be text(ip of request)
|
|
66
|
+
when ip == "127.0.0.1" or ip == "::1" or ip == "::ffff:127.0.0.1"
|
|
67
|
+
give {"local": true}
|
|
68
|
+
let want be env("LAMPSON_WEB_TOKEN", "")
|
|
69
|
+
when want != "" and token == want
|
|
70
|
+
give {"local": false}
|
|
71
|
+
give nothing
|
|
72
|
+
|
|
73
|
+
-- salida del pty como texto (la doc de 0.6.8 ya lo dice así; is_bytes por si cambia)
|
|
74
|
+
task as_text(x)
|
|
75
|
+
when is_bytes(x)
|
|
76
|
+
give decode(x, "utf8_lossy")
|
|
77
|
+
give x
|
|
78
|
+
|
|
79
|
+
-- shell para el terminal web: [cmd, args]. Windows: pwsh si está, si no PowerShell 5; unix: bash de login.
|
|
80
|
+
-- En pwsh, los directorios de `ls` salen sin fondo azul (el default de $PSStyle se lee mal en un tema oscuro).
|
|
81
|
+
task term_shell()
|
|
82
|
+
when env("OS", "") == "Windows_NT"
|
|
83
|
+
try
|
|
84
|
+
run("pwsh", ["-NoLogo", "-Command", "exit"], 10)
|
|
85
|
+
give ["pwsh", ["-NoLogo", "-NoExit", "-Command", "$PSStyle.FileInfo.Directory = $PSStyle.Foreground.BrightBlue"]]
|
|
86
|
+
recover err
|
|
87
|
+
give ["powershell", ["-NoLogo"]]
|
|
88
|
+
give ["bash", ["-l"]]
|
|
89
|
+
|
|
90
|
+
-- cwd del terminal: la ruta REAL del proyecto (LAMPSON_WORKSPACE), no la junction ./workspace —
|
|
91
|
+
-- así el prompt muestra dónde estás de verdad y coincide con el header.
|
|
92
|
+
task term_cwd()
|
|
93
|
+
give env("LAMPSON_WORKSPACE", "workspace")
|
|
94
|
+
|
|
95
|
+
task boot(profile, mode, ask_fn, pname, model)
|
|
96
|
+
let cfg be provider.config_for(pname, model)
|
|
97
|
+
let p be when contains(agents.PROFILES, profile) then profile otherwise "build"
|
|
98
|
+
let opts be loop.default_opts(agents.registry_for(p), agents.catalog_for(p), ask_fn)
|
|
99
|
+
set opts to loop.with_steps(opts, agents.steps_for(p))
|
|
100
|
+
set opts to loop.with_inbox(opts, agents.parent_inbox, "")
|
|
101
|
+
set opts to loop.with_mode(opts, mode)
|
|
102
|
+
give {"cfg": cfg, "system": {"role": "system", "content": prompt.build(agents.env_info(cfg), agents.addendum_for(p))}, "opts": opts, "profile": p}
|
|
103
|
+
|
|
104
|
+
-- Subagente en background: `agent Child` (lib/agents.syn) solo puede llamar tasks TOP-LEVEL de este
|
|
105
|
+
-- programa; esta es su puerta de entrada. Bajo serve el agente sobrevive al request que lo lanzó.
|
|
106
|
+
task lampson_subagent(spec_json)
|
|
107
|
+
give agents.run_child_json(spec_json)
|
|
108
|
+
|
|
109
|
+
-- servers MCP: supervisores (agentes) lanzados al arrancar; los handlers leen su estado del blackboard
|
|
110
|
+
mcp.start_all(8)
|
|
111
|
+
|
|
112
|
+
-- marca de corrida (blackboard): session.save la estampa en meta.run; reanudar una sesión guardada por
|
|
113
|
+
-- OTRA corrida dispara la nota de procesos muertos (proc.resume_note) para que el modelo no los crea vivos
|
|
114
|
+
share {"id": text(now())} as "lampson:run"
|
|
115
|
+
share {"kind": "web"} as "lampson:ui"
|
|
116
|
+
|
|
117
|
+
serve on 8080
|
|
118
|
+
max_streams 50
|
|
119
|
+
auth with check_client
|
|
120
|
+
static "./public"
|
|
121
|
+
|
|
122
|
+
route "GET /api/sessions" requires auth
|
|
123
|
+
give {"sessions": session.list()}
|
|
124
|
+
|
|
125
|
+
route "GET /api/sessions/:id" requires auth
|
|
126
|
+
when not session.exists(params.id)
|
|
127
|
+
give not_found("session not found")
|
|
128
|
+
let doc be session.load(params.id)
|
|
129
|
+
let visible be where(doc["messages"], (m) => m["role"] != "system")
|
|
130
|
+
give {"id": doc["id"], "updated": doc["updated"], "meta": doc["meta"], "messages": visible}
|
|
131
|
+
|
|
132
|
+
route "GET /api/config" requires auth
|
|
133
|
+
let cfg be provider.config()
|
|
134
|
+
give {"provider": cfg["provider"], "model": cfg["model"], "wire": cfg["wire"], "vision": provider.supports_vision(cfg), "providers": provider.providers(), "configured": provider.configured(), "permission": lower(env("LAMPSON_PERMISSION", "ask")), "agents": agents.names(), "workspace": env("LAMPSON_WORKSPACE", "./workspace"), "profiles": agents.PROFILES}
|
|
135
|
+
|
|
136
|
+
-- proveedor/modelo por defecto y API keys → .lampson/config.json (local). La key nunca vuelve al cliente.
|
|
137
|
+
route "POST /api/settings" requires auth
|
|
138
|
+
expect body {provider: text}
|
|
139
|
+
let b be json of request
|
|
140
|
+
let name be lower(text(b["provider"]))
|
|
141
|
+
when not contains(provider.PRESETS, name)
|
|
142
|
+
give fail(400, "unknown provider " + name)
|
|
143
|
+
when contains(b, "key")
|
|
144
|
+
settings.set_key(name, text(b["key"]))
|
|
145
|
+
let model be when contains(b, "model") then trim(text(b["model"])) otherwise ""
|
|
146
|
+
settings.set_default(name, model)
|
|
147
|
+
let ncfg be provider.config_for(name, "")
|
|
148
|
+
give {"ok": true, "providers": provider.providers(), "provider": name, "model": ncfg["model"], "vision": provider.supports_vision(ncfg), "configured": provider.configured()}
|
|
149
|
+
|
|
150
|
+
route "POST /api/sessions/delete" requires auth
|
|
151
|
+
expect body {id: text}
|
|
152
|
+
let b be json of request
|
|
153
|
+
give {"result": session.delete(text(b["id"]))}
|
|
154
|
+
|
|
155
|
+
route "GET /api/tree" requires auth
|
|
156
|
+
give tree.tree(6)
|
|
157
|
+
|
|
158
|
+
-- Terminal real en el navegador: un shell dentro de un pseudo-terminal (pty) por conexión WebSocket.
|
|
159
|
+
-- Navegador → servidor: texto JSON {type: "in", data: teclas} | {type: "resize", cols, rows}.
|
|
160
|
+
-- Servidor → navegador: frames de texto con prefijo: "o" + salida cruda del pty (ANSI incluido, xterm.js
|
|
161
|
+
-- la dibuja) | "c" + JSON de control ({type: hello|exit}).
|
|
162
|
+
-- El shell vive lo que vive el socket: cerrar el panel lo mata (el runtime no deja huérfanos).
|
|
163
|
+
route "GET /api/term" requires auth
|
|
164
|
+
socket
|
|
165
|
+
let sh be term_shell()
|
|
166
|
+
let p be proc_spawn(sh[0], sh[1], {"cwd": term_cwd(), "pty": true, "cols": 120, "rows": 32})
|
|
167
|
+
ws_send(socket, "c" + json_encode({"type": "hello", "shell": sh[0], "pid": proc_stats(p)["pid"], "cwd": term_cwd()}))
|
|
168
|
+
let open be true
|
|
169
|
+
while open
|
|
170
|
+
let ev be select({"ui": socket, "sh": p}, 600)
|
|
171
|
+
when ev == nothing
|
|
172
|
+
set open to proc_status(p) == "running"
|
|
173
|
+
otherwise when ev["name"] == "ui"
|
|
174
|
+
when ev["type"] == "close"
|
|
175
|
+
set open to false
|
|
176
|
+
otherwise when ev["type"] == "binary"
|
|
177
|
+
proc_send(p, ev["data"])
|
|
178
|
+
otherwise
|
|
179
|
+
let m be json_decode(ev["data"])
|
|
180
|
+
when m["type"] == "in"
|
|
181
|
+
proc_send(p, m["data"])
|
|
182
|
+
otherwise when m["type"] == "resize"
|
|
183
|
+
proc_resize(p, floor(m["cols"]), floor(m["rows"]))
|
|
184
|
+
otherwise when ev["type"] == "exit"
|
|
185
|
+
ws_send(socket, "c" + json_encode({"type": "exit", "code": ev["data"]["exit_code"]}))
|
|
186
|
+
set open to false
|
|
187
|
+
otherwise
|
|
188
|
+
ws_send(socket, "o" + as_text(ev["data"]))
|
|
189
|
+
proc_close(p)
|
|
190
|
+
|
|
191
|
+
route "GET /api/update" requires auth
|
|
192
|
+
give update.check()
|
|
193
|
+
|
|
194
|
+
route "POST /api/update" requires auth
|
|
195
|
+
give {"result": update.apply()}
|
|
196
|
+
|
|
197
|
+
route "GET /api/git" requires auth
|
|
198
|
+
give git.status()
|
|
199
|
+
|
|
200
|
+
route "GET /api/memory" requires auth
|
|
201
|
+
give {"dir": memo.dir(), "notes": memo.list()}
|
|
202
|
+
|
|
203
|
+
route "GET /api/memory/note" requires auth
|
|
204
|
+
when not contains(query, "name")
|
|
205
|
+
give fail(400, "name required")
|
|
206
|
+
give {"name": query.name, "content": memo.note_read(query.name)}
|
|
207
|
+
|
|
208
|
+
-- traza legible de una sesión (ver lib/trace.syn)
|
|
209
|
+
route "GET /api/trace" requires auth
|
|
210
|
+
when not contains(query, "session")
|
|
211
|
+
give fail(400, "session required")
|
|
212
|
+
when not matches(query.session, "[A-Za-z0-9_-]{1,40}")
|
|
213
|
+
give fail(400, "bad session id")
|
|
214
|
+
let n be when contains(query, "tail") then floor(number(query.tail)) otherwise 400
|
|
215
|
+
give {"session": query.session, "file": trace.file_of(query.session), "trace": trace.tail(query.session, n)}
|
|
216
|
+
|
|
217
|
+
-- modelos que declara la API del proveedor (para no tipear nombres a ciegas: DeepSeek es case-sensitive)
|
|
218
|
+
route "GET /api/models" requires auth
|
|
219
|
+
let pname be when contains(query, "provider") then lower(text(query.provider)) otherwise ""
|
|
220
|
+
let cfg be provider.config_for(pname, "")
|
|
221
|
+
give provider.list_models(cfg)
|
|
222
|
+
|
|
223
|
+
route "GET /api/todo" requires auth
|
|
224
|
+
-- ?session=<id> → la lista de esa sesión; sin query, la de la sesión en curso
|
|
225
|
+
when contains(query, "session") and matches(query.session, "[A-Za-z0-9_-]{1,40}")
|
|
226
|
+
give {"items": todo.load(query.session)}
|
|
227
|
+
give {"items": todo.load(nothing)}
|
|
228
|
+
|
|
229
|
+
route "GET /api/mcp" requires auth
|
|
230
|
+
give {"servers": mcp.summary(), "global": mcp.GLOBAL_CONFIG, "project": mcp.PROJECT_CONFIG}
|
|
231
|
+
|
|
232
|
+
-- conectar un server MCP desde la UI: se escribe en el mcp.json del scope y arranca su supervisor
|
|
233
|
+
route "POST /api/mcp/add" requires auth
|
|
234
|
+
expect body {name: text, command: text}
|
|
235
|
+
let b be json of request
|
|
236
|
+
let scope be when contains(b, "scope") then lower(text(b["scope"])) otherwise "global"
|
|
237
|
+
let envm be when contains(b, "env") then b["env"] otherwise {}
|
|
238
|
+
try
|
|
239
|
+
give {"ok": true, "result": mcp.add_server(text(b["name"]), text(b["command"]), envm, scope), "servers": mcp.summary()}
|
|
240
|
+
recover err
|
|
241
|
+
give fail(400, text(err))
|
|
242
|
+
|
|
243
|
+
route "POST /api/mcp/remove" requires auth
|
|
244
|
+
expect body {name: text}
|
|
245
|
+
let b be json of request
|
|
246
|
+
try
|
|
247
|
+
give {"ok": true, "result": mcp.remove_server(text(b["name"])), "servers": mcp.summary()}
|
|
248
|
+
recover err
|
|
249
|
+
give fail(400, text(err))
|
|
250
|
+
|
|
251
|
+
-- language servers (navegación semántica): listar, agregar (preset o comando propio), quitar
|
|
252
|
+
route "GET /api/lsp" requires auth
|
|
253
|
+
give {"servers": lsp.summary(), "presets": lsp.PRESETS, "global": lsp.GLOBAL_CONFIG, "project": lsp.PROJECT_CONFIG}
|
|
254
|
+
|
|
255
|
+
route "POST /api/lsp/add" requires auth
|
|
256
|
+
expect body {name: text}
|
|
257
|
+
let b be json of request
|
|
258
|
+
let scope be when contains(b, "scope") then lower(text(b["scope"])) otherwise "global"
|
|
259
|
+
let cmd be when contains(b, "command") then text(b["command"]) otherwise nothing
|
|
260
|
+
let langs be when contains(b, "languages") then b["languages"] otherwise nothing
|
|
261
|
+
try
|
|
262
|
+
give {"ok": true, "result": lsp.add_server(text(b["name"]), cmd, langs, scope), "servers": lsp.summary()}
|
|
263
|
+
recover err
|
|
264
|
+
give fail(400, text(err))
|
|
265
|
+
|
|
266
|
+
route "POST /api/lsp/remove" requires auth
|
|
267
|
+
expect body {name: text}
|
|
268
|
+
let b be json of request
|
|
269
|
+
try
|
|
270
|
+
give {"ok": true, "result": lsp.remove_server(text(b["name"])), "servers": lsp.summary()}
|
|
271
|
+
recover err
|
|
272
|
+
give fail(400, text(err))
|
|
273
|
+
|
|
274
|
+
-- lámparas (plugins de tools): listar y encender/apagar desde la barra superior de la UI
|
|
275
|
+
route "GET /api/lamps" requires auth
|
|
276
|
+
give {"lamps": lamps.summary(), "global": lamps.GLOBAL_DIR, "project": lamps.PROJECT_DIR}
|
|
277
|
+
|
|
278
|
+
-- el usuario corre una tool de una lámpara encendida él mismo (sin pasar por el modelo)
|
|
279
|
+
route "POST /api/lamps/run" requires auth
|
|
280
|
+
expect body {tool: text}
|
|
281
|
+
let b be json of request
|
|
282
|
+
let args be when contains(b, "args") then b["args"] otherwise {}
|
|
283
|
+
try
|
|
284
|
+
give {"ok": true, "output": lamps.call(text(b["tool"]), args)}
|
|
285
|
+
recover err
|
|
286
|
+
give fail(400, text(err))
|
|
287
|
+
|
|
288
|
+
route "POST /api/lamps/remove" requires auth
|
|
289
|
+
expect body {name: text}
|
|
290
|
+
let b be json of request
|
|
291
|
+
try
|
|
292
|
+
give {"ok": true, "result": lamps.remove(text(b["name"])), "lamps": lamps.summary()}
|
|
293
|
+
recover err
|
|
294
|
+
give fail(400, text(err))
|
|
295
|
+
|
|
296
|
+
route "POST /api/lamps/toggle" requires auth
|
|
297
|
+
expect body {name: text}
|
|
298
|
+
let b be json of request
|
|
299
|
+
let on be contains(b, "enabled") and b["enabled"] == true
|
|
300
|
+
try
|
|
301
|
+
give {"ok": true, "result": lamps.set_enabled(text(b["name"]), on), "lamps": lamps.summary()}
|
|
302
|
+
recover err
|
|
303
|
+
give fail(400, text(err))
|
|
304
|
+
|
|
305
|
+
route "GET /api/agents" requires auth
|
|
306
|
+
agents.prune(10, 1800)
|
|
307
|
+
give {"agents": agents.list_children()}
|
|
308
|
+
|
|
309
|
+
-- Eventos en vivo para la UI (SSE): lo que publican los supervisores de procesos (proc.<name>: líneas y
|
|
310
|
+
-- cambios de estado) y los subagentes (subagent.started|done|<id>). Un bus por programa; cada cliente
|
|
311
|
+
-- tiene su cola acotada (drop_oldest). Heartbeat cada 25 s para que el proxy/navegador no cierre.
|
|
312
|
+
route "GET /api/events" requires auth
|
|
313
|
+
stream
|
|
314
|
+
let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*"], {"max_queue": 512})
|
|
315
|
+
let open be true
|
|
316
|
+
while open
|
|
317
|
+
let ev be bus_recv(sub, 25)
|
|
318
|
+
try
|
|
319
|
+
when ev == nothing
|
|
320
|
+
send {"topic": "ping"} as "event"
|
|
321
|
+
otherwise
|
|
322
|
+
send {"topic": ev["topic"], "data": ev["data"]} as "event"
|
|
323
|
+
recover err
|
|
324
|
+
set open to false
|
|
325
|
+
bus_unsubscribe(sub)
|
|
326
|
+
|
|
327
|
+
route "GET /api/agents/log" requires auth
|
|
328
|
+
when not contains(query, "id")
|
|
329
|
+
give fail(400, "id required")
|
|
330
|
+
when not matches(query.id, "[a-z]+-[0-9]+")
|
|
331
|
+
give fail(400, "bad id")
|
|
332
|
+
try
|
|
333
|
+
let meta be nothing
|
|
334
|
+
try
|
|
335
|
+
set meta to json_decode(read_file(agents.DIR + "/" + query.id + ".json"))
|
|
336
|
+
recover e2
|
|
337
|
+
set meta to nothing
|
|
338
|
+
give {"id": query.id, "log": read_file(agents.DIR + "/" + query.id + ".log"), "agent": meta}
|
|
339
|
+
recover err
|
|
340
|
+
give fail(404, "no such sub-agent")
|
|
341
|
+
|
|
342
|
+
route "POST /api/agents/stop" requires auth
|
|
343
|
+
expect body {id: text}
|
|
344
|
+
let b be json of request
|
|
345
|
+
when not matches(b["id"], "[a-z]+-[0-9]+")
|
|
346
|
+
give fail(400, "bad id")
|
|
347
|
+
write_file(agents.DIR + "/" + b["id"] + ".stop", "stop")
|
|
348
|
+
bus_publish("subagent." + b["id"], {"kind": "stop", "line": "stop requested from the UI"})
|
|
349
|
+
give {"result": "stop requested for " + b["id"]}
|
|
350
|
+
|
|
351
|
+
route "GET /api/proc" requires auth
|
|
352
|
+
give {"processes": proc.list()}
|
|
353
|
+
|
|
354
|
+
route "GET /api/proc/log" requires auth
|
|
355
|
+
when not contains(query, "name")
|
|
356
|
+
give fail(400, "name required")
|
|
357
|
+
let n be when contains(query, "tail") then number(query.tail) otherwise 200
|
|
358
|
+
give {"name": query.name, "running": proc.alive(query.name), "log": proc.logs(query.name, n)}
|
|
359
|
+
|
|
360
|
+
route "GET /api/ports" requires auth
|
|
361
|
+
give {"ports": proc.listeners()}
|
|
362
|
+
|
|
363
|
+
route "POST /api/ports/kill" requires auth
|
|
364
|
+
expect body {pid: number}
|
|
365
|
+
let b be json of request
|
|
366
|
+
give {"result": proc.kill_pid(b["pid"])}
|
|
367
|
+
|
|
368
|
+
route "POST /api/proc/stop" requires auth
|
|
369
|
+
expect body {name: text}
|
|
370
|
+
let b be json of request
|
|
371
|
+
give {"result": proc.halt(b["name"])}
|
|
372
|
+
|
|
373
|
+
route "GET /api/file" requires auth
|
|
374
|
+
when not contains(query, "path")
|
|
375
|
+
give fail(400, "path required")
|
|
376
|
+
give tree.file_content(query.path)
|
|
377
|
+
|
|
378
|
+
route "POST /api/approve" requires auth
|
|
379
|
+
expect body {id: text, decision: bool}
|
|
380
|
+
let b be json of request
|
|
381
|
+
state_set("approval:" + b["id"], when b["decision"] then "yes" otherwise "no")
|
|
382
|
+
give {"ok": true}
|
|
383
|
+
|
|
384
|
+
route "POST /api/chat" requires auth
|
|
385
|
+
expect body {message: text}
|
|
386
|
+
let b be json of request
|
|
387
|
+
let message be b["message"]
|
|
388
|
+
let sid be when contains(b, "session") then text(b["session"]) otherwise ""
|
|
389
|
+
let want be when contains(b, "agent") then lower(text(b["agent"])) otherwise "build"
|
|
390
|
+
let mode be when contains(b, "permission") then lower(text(b["permission"])) otherwise lower(env("LAMPSON_PERMISSION", "ask"))
|
|
391
|
+
when mode != "ask" and mode != "yolo" and mode != "strict"
|
|
392
|
+
set mode to "ask"
|
|
393
|
+
let pname be when contains(b, "provider") then lower(text(b["provider"])) otherwise ""
|
|
394
|
+
let pmodel be when contains(b, "model") then text(b["model"]) otherwise ""
|
|
395
|
+
stream
|
|
396
|
+
task emit(kind, data, tag)
|
|
397
|
+
trace.event(sid, kind, data, tag)
|
|
398
|
+
send {"kind": kind, "data": data, "tag": tag} as "event"
|
|
399
|
+
-- aprobación humana desde la UI: pregunta por SSE y espera la respuesta (o el timeout)
|
|
400
|
+
task ask_web(name, args, why)
|
|
401
|
+
let id be text(floor(now() * 1000)) + "-" + name
|
|
402
|
+
send {"kind": "approval_request", "data": {"id": id, "name": name, "args": args, "why": why, "timeout": APPROVAL_TIMEOUT}} as "event"
|
|
403
|
+
let waited be 0
|
|
404
|
+
let answer be nothing
|
|
405
|
+
while answer == nothing and waited < APPROVAL_TIMEOUT
|
|
406
|
+
set answer to state_get("approval:" + id, nothing)
|
|
407
|
+
when answer == nothing
|
|
408
|
+
sleep(0.5)
|
|
409
|
+
set waited to waited + 0.5
|
|
410
|
+
state_delete("approval:" + id)
|
|
411
|
+
let ok be answer == "yes"
|
|
412
|
+
send {"kind": "approval_result", "data": {"id": id, "approved": ok, "timeout": answer == nothing}} as "event"
|
|
413
|
+
give ok
|
|
414
|
+
let ctx be boot(want, mode, ask_web, pname, pmodel)
|
|
415
|
+
let messages be [ctx["system"]]
|
|
416
|
+
when sid != ""
|
|
417
|
+
when session.exists(sid)
|
|
418
|
+
let prev_doc be session.load(sid)
|
|
419
|
+
let prev be prev_doc["messages"]
|
|
420
|
+
set messages to [ctx["system"]] + slice(prev, 1, length(prev))
|
|
421
|
+
-- sesión guardada por otra corrida de lampson: sus procesos ya no viven — decírselo
|
|
422
|
+
-- al modelo con el estado real (una sola vez: el próximo save re-estampa meta.run)
|
|
423
|
+
when session.from_other_run(prev_doc)
|
|
424
|
+
let note be proc.resume_note(sid)
|
|
425
|
+
when note != ""
|
|
426
|
+
set messages to append(messages, {"role": "user", "content": note})
|
|
427
|
+
trace.event(sid, "inbox", note, "")
|
|
428
|
+
send {"kind": "inbox", "data": note, "tag": ""} as "event"
|
|
429
|
+
otherwise
|
|
430
|
+
set sid to ""
|
|
431
|
+
when sid == ""
|
|
432
|
+
set sid to session.new_id()
|
|
433
|
+
-- sesión actual (blackboard): la tool todo guarda su lista por sesión (lib/tools/todo.syn)
|
|
434
|
+
share {"id": sid} as "lampson:session"
|
|
435
|
+
send {"kind": "session", "data": sid} as "event"
|
|
436
|
+
send {"kind": "mode", "data": {"agent": ctx["profile"], "permission": mode, "provider": ctx["cfg"]["provider"], "model": ctx["cfg"]["model"], "has_key": ctx["cfg"]["has_key"]}} as "event"
|
|
437
|
+
when starts_with(trim(message), "!")
|
|
438
|
+
-- comando del USUARIO: sin política de permisos, salida al contexto; el modelo no interviene
|
|
439
|
+
let cmd be trim(slice(trim(message), 1, length(trim(message))))
|
|
440
|
+
let bash_tool be agents.registry_for("build")["bash"]
|
|
441
|
+
send {"kind": "tool_call", "data": {"id": "user", "name": "bash", "args": {"command": cmd}}} as "event"
|
|
442
|
+
let out be call_tool(bash_tool, {"command": cmd, "timeout": 600})
|
|
443
|
+
send {"kind": "tool_result", "data": {"call": {"id": "user", "name": "bash", "args": {"command": cmd}}, "output": out}} as "event"
|
|
444
|
+
set messages to append(messages, {"role": "user", "content": "[The user ran this command in the shell themselves]\n$ " + cmd + "\n" + tools.truncate(out, 6000)})
|
|
445
|
+
session.save(sid, messages, {"title": session.title_of(messages)})
|
|
446
|
+
send {"kind": "done", "data": {"text": "", "steps": 0, "usage": {"input": 0, "output": 0}, "stopped": "user_command", "session": sid}} as "event"
|
|
447
|
+
otherwise
|
|
448
|
+
-- imágenes pegadas en la UI: [{media_type, data(base64)}] — el cliente ya las redujo; tope 5 MB por mensaje
|
|
449
|
+
let images be []
|
|
450
|
+
when contains(b, "images")
|
|
451
|
+
let total be 0
|
|
452
|
+
each im in b["images"]
|
|
453
|
+
when contains(im, "data") and contains(im, "media_type")
|
|
454
|
+
set total to total + length(im["data"])
|
|
455
|
+
when total <= 5000000
|
|
456
|
+
let entry be {"media_type": text(im["media_type"]), "data": text(im["data"])}
|
|
457
|
+
when contains(im, "w") and contains(im, "h")
|
|
458
|
+
set entry to {"media_type": text(im["media_type"]), "data": text(im["data"]), "w": floor(number(im["w"])), "h": floor(number(im["h"]))}
|
|
459
|
+
set images to append(images, entry)
|
|
460
|
+
let umsg be {"role": "user", "content": message}
|
|
461
|
+
when length(images) > 0
|
|
462
|
+
set umsg to {"role": "user", "content": message, "images": images}
|
|
463
|
+
set messages to append(messages, umsg)
|
|
464
|
+
trace.user(sid, message, ctx["profile"], mode, ctx["cfg"]["model"])
|
|
465
|
+
let result be loop.run_turn(ctx["cfg"], messages, ctx["opts"], emit)
|
|
466
|
+
trace.turn_end(sid, result)
|
|
467
|
+
session.save(sid, result["messages"], {"title": session.title_of(result["messages"])})
|
|
468
|
+
send {"kind": "done", "data": {"text": result["text"], "steps": result["steps"], "usage": result["usage"], "stopped": result["stopped"], "session": sid}} as "event"
|