lampson 0.2.6 → 0.2.8
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/README.md +16 -10
- package/bin/lampson.js +13 -7
- package/chat.syn +36 -31
- package/lampson.ps1 +11 -1
- package/lampson.sh +7 -0
- package/lib/agents.syn +10 -10
- package/lib/fs.syn +226 -0
- package/lib/loop.syn +9 -8
- package/lib/permission.syn +40 -14
- package/lib/plugins.syn +408 -0
- package/lib/prompt.syn +3 -2
- package/lib/sched_run.syn +10 -10
- package/lib/schedule.syn +36 -21
- package/lib/tools/fetch.syn +573 -0
- package/lib/tools/memo.syn +27 -5
- package/lib/tools/url.syn +220 -0
- package/lib/tools.syn +32 -30
- package/lib/workspaces.syn +34 -9
- package/package.json +12 -4
- package/{lamps/example-hello/lamp.json → plugins/example-hello/plugin.json} +2 -2
- package/plugins/example-hello/plugin.syn +19 -0
- package/public/css/panel.css +3 -3
- package/public/css/sidebar.css +21 -0
- package/public/hub.html +1 -1
- package/public/index.html +3 -3
- package/public/js/app.js +1 -1
- package/public/js/chat.js +4 -4
- package/public/js/core.js +1 -1
- package/public/js/memory.js +2 -1
- package/public/js/{lamps.js → plugins.js} +43 -41
- package/public/js/schedules.js +9 -9
- package/public/js/tree.js +212 -10
- package/public/js/workspaces.js +1 -1
- package/skills/lampson/SKILL.md +26 -11
- package/web.syn +32 -17
- package/lamps/example-hello/lamp.syn +0 -19
- package/lib/lamps.syn +0 -386
package/skills/lampson/SKILL.md
CHANGED
|
@@ -25,6 +25,14 @@ description: How this harness works — tools, workspace mount, permissions, age
|
|
|
25
25
|
more context. `replace_all=true` for intentional multi-replace.
|
|
26
26
|
- `read` returns up to 2000 lines; use `offset`/`limit` for big files. Outputs > 30k chars are truncated.
|
|
27
27
|
- `find` is a simple glob (`*.ts`, `test_*`, `*config*`), `grep` is regex by default (`regex=false` for literals).
|
|
28
|
+
- `fetch(url)` returns a web page as Markdown (about a tenth of the characters of its HTML: scripts, styles,
|
|
29
|
+
nav and footers stripped, links absolute, code blocks and tables kept; a site that serves Markdown is
|
|
30
|
+
taken as is). Follows up to 5 redirects. Long pages come back head+tail and the full text is saved to
|
|
31
|
+
`.lampson/spill/fetch-<host>-<id>.md` — page it with `read offset/limit` or `grep`, never re-fetch; the
|
|
32
|
+
same URL within 20 minutes is served from that file. `format=text` for prose without links, `format=html`
|
|
33
|
+
to see the raw markup. localhost / private hosts ask the user (good for checking your own dev server);
|
|
34
|
+
cloud metadata endpoints and URLs carrying tokens are refused. Binary content (images, PDF) is reported,
|
|
35
|
+
not returned — use `bash` with `curl -o` for downloads.
|
|
28
36
|
- `lsp(op=symbols, path)` is the cheapest way to understand a big file: every function/class/variable
|
|
29
37
|
with its line range — then `read` only the range you need. `definition` / `references` / `hover` /
|
|
30
38
|
`implementation` take 1-based `line` + `character` ON the identifier. If no server is configured for
|
|
@@ -43,7 +51,7 @@ description: How this harness works — tools, workspace mount, permissions, age
|
|
|
43
51
|
## Scheduled tasks (`schedule` tool)
|
|
44
52
|
- When the user says "every day at 9", "each 6 hours", "on Mondays", "periodically", "send me", propose
|
|
45
53
|
`schedule(action=add, name, at, kind, …)`: `at` = `every 6h` | `daily 09:00` | `mon,wed 08:30` |
|
|
46
|
-
`weekdays 09:00`; one-time: `today 15:14` | `tomorrow 09:00` | `once 2026-08-29 15:14` | `in 2h` (it turns itself off after running) — the USER'S LOCAL time: write the hour as they say it, never convert to UTC (the tool result shows the next run with its offset). Tasks belong to the current workspace. `kind=
|
|
54
|
+
`weekdays 09:00`; one-time: `today 15:14` | `tomorrow 09:00` | `once 2026-08-29 15:14` | `in 2h` (it turns itself off after running) — the USER'S LOCAL time: write the hour as they say it, never convert to UTC (the tool result shows the next run with its offset). Tasks belong to the current workspace. `kind=plugin` (a plugin that is ON: plugin + tool + args), `kind=bash` (one
|
|
47
55
|
command that finishes on its own), `kind=prompt` (an unattended agent run: write a self-contained prompt —
|
|
48
56
|
what to do, how to verify, what to report — and pick `agent` build/review/plan/explore).
|
|
49
57
|
- `permission` is the envelope of a `prompt` run with nobody watching: `strict` (dangerous → denied),
|
|
@@ -95,6 +103,10 @@ the terminal button and run `npm run dev`), then tell me the URL".
|
|
|
95
103
|
## Project memory (persistent notes)
|
|
96
104
|
- `memory(write, name, content)` saves a Markdown note about THIS project in Lampson's `memory/<project>/`
|
|
97
105
|
folder (outside the repo). The system prompt lists your notes; `memory(read, name)` loads one.
|
|
106
|
+
- That folder is OUTSIDE the workspace and outside the reach of read/ls/grep/bash (they are confined to the
|
|
107
|
+
project): the `memory` tool is the only door. Never look for it with `ls`, never write notes with bash, and
|
|
108
|
+
never store notes inside the repo (no `.lampson/notes/`). If the tool says the folder is unreachable, tell
|
|
109
|
+
the user to run `lampson` again in the project (it repairs the link) — do not improvise.
|
|
98
110
|
- Save what you would otherwise rediscover: how to run/test, env quirks, decisions, where things live,
|
|
99
111
|
root causes of bugs. Update notes instead of contradicting them. The user can read and edit them.
|
|
100
112
|
|
|
@@ -124,15 +136,18 @@ the terminal button and run `npm run dev`), then tell me the URL".
|
|
|
124
136
|
(`workspace/.agents/skills`, `workspace/.claude/skills`, and the global `~/.agents/skills` / `~/.claude/skills`
|
|
125
137
|
mounted as `.lampson/skills-global` / `.lampson/skills-claude`). Frontmatter `name:` + `description:`.
|
|
126
138
|
`skill(action=install, source=owner/repo, name=x, scope=global|project)` installs one (always asks the user).
|
|
127
|
-
- **
|
|
128
|
-
|
|
139
|
+
- **Plugins** = tools you can create for this project without touching the harness (they were called
|
|
140
|
+
"lamps" until 0.2.6 — the user may still say "lámpara"; a `.lampson/lamps/` folder still works):
|
|
141
|
+
`plugin(action=create, name, manifest, files={"plugin.syn": "…"})` writes `workspace/.lampson/plugins/<name>/`,
|
|
129
142
|
validates the manifest and runs `synsema check` on a syn entry (it does not run or enable anything —
|
|
130
|
-
like dsh's cordis_define). Then `
|
|
143
|
+
like dsh's cordis_define). Then `plugin(action=enable, name)` — the user must approve (a plugin is off until
|
|
131
144
|
a human turns it on). Build one when a task needs a reusable project-specific tool. Manifest:
|
|
132
|
-
`{"name", "description", "kind": "syn"|"exec", "entry": "
|
|
133
|
-
"caps": "file.read=workspace/*" (syn, optional extra ceiling over stdout,time,env=
|
|
134
|
-
"tools": [{"name", "description", "parameters": {JSON Schema}, "readonly": bool}]}`.
|
|
135
|
-
digits, `-` (no `_`). Each call runs the
|
|
136
|
-
for `syn`, the command for `exec`. Inside, read `
|
|
137
|
-
(`require env("
|
|
138
|
-
manifest's `caps`; ask only for what the tool needs. Once on, its tools are `
|
|
145
|
+
`{"name", "description", "kind": "syn"|"exec", "entry": "plugin.syn" (syn) | "command": "python plugin.py" (exec),
|
|
146
|
+
"caps": "file.read=workspace/*" (syn, optional extra ceiling over stdout,time,env=PLUGIN_*), "timeout": 60,
|
|
147
|
+
"tools": [{"name", "description", "parameters": {JSON Schema}, "readonly": bool}]}`. Plugin names: letters,
|
|
148
|
+
digits, `-` (no `_`). Each call runs the plugin as ONE child process: `synsema run --cap-set <ceiling> entry`
|
|
149
|
+
for `syn`, the command for `exec`. Inside, read `PLUGIN_TOOL` and `PLUGIN_ARGS` (JSON) from env
|
|
150
|
+
(`require env("PLUGIN_*")` in a .syn) and print the result to stdout. A syn plugin cannot use more than its
|
|
151
|
+
manifest's `caps`; ask only for what the tool needs. Once on, its tools are `plugin_<name>_<tool>`.
|
|
152
|
+
Not the same thing as a **lamp** from lamps.sh (a portable, ceiling-enforced capability unit for any MCP
|
|
153
|
+
agent): if the user wants one of those, it is `lamp add <ref>` + `lamp mcp` as an MCP server, not a plugin.
|
package/web.syn
CHANGED
|
@@ -34,8 +34,8 @@ require file("workspace")
|
|
|
34
34
|
require file("workspace/*")
|
|
35
35
|
require file.read("skills")
|
|
36
36
|
require file.read("skills/*")
|
|
37
|
-
require file.read("
|
|
38
|
-
require file.read("
|
|
37
|
+
require file.read("plugins")
|
|
38
|
+
require file.read("plugins/*")
|
|
39
39
|
require file("memory")
|
|
40
40
|
require file("memory/*")
|
|
41
41
|
require file(".lampson")
|
|
@@ -50,6 +50,7 @@ use "./lib/loop.syn" as loop
|
|
|
50
50
|
use "./lib/session.syn" as session
|
|
51
51
|
use "./lib/agents.syn" as agents
|
|
52
52
|
use "./lib/tree.syn" as tree
|
|
53
|
+
use "./lib/fs.syn" as fs
|
|
53
54
|
use "./lib/git.syn" as git
|
|
54
55
|
use "./lib/tools/proc.syn" as proc
|
|
55
56
|
use "./lib/tools/memo.syn" as memo
|
|
@@ -57,7 +58,7 @@ use "./lib/update.syn" as update
|
|
|
57
58
|
use "./lib/settings.syn" as settings
|
|
58
59
|
use "./lib/trace.syn" as trace
|
|
59
60
|
use "./lib/mcp.syn" as mcp
|
|
60
|
-
use "./lib/
|
|
61
|
+
use "./lib/plugins.syn" as plugins
|
|
61
62
|
use "./lib/lsp.syn" as lsp
|
|
62
63
|
use "./lib/tools/todo.syn" as todo
|
|
63
64
|
use "./lib/schedule.syn" as schedule
|
|
@@ -105,7 +106,7 @@ task lampson_subagent(spec_json)
|
|
|
105
106
|
-- TAREAS PROGRAMADAS (lib/schedule.syn): este proceso es el residente de lampson — con `lampson --daemon start`
|
|
106
107
|
-- (synsema daemon) queda en background y ejecuta lo programado aunque no haya ninguna UI abierta. Un tick cada
|
|
107
108
|
-- 30 s (cron_every de Synsema: intervalo puro; la hora de reloj la resuelve schedule.syn) corre lo vencido:
|
|
108
|
-
--
|
|
109
|
+
-- plugins, comandos y corridas del agente con su sobre de permisos; una aprobación en una corrida desatendida
|
|
109
110
|
-- sale por lib/approvals.syn (panel «Aprobaciones» de la UI, webhook firmado, link de un solo uso).
|
|
110
111
|
task sched_tick()
|
|
111
112
|
give sched_run.tick()
|
|
@@ -193,7 +194,10 @@ serve on 8080
|
|
|
193
194
|
give {"result": session.delete(text(b["id"]))}
|
|
194
195
|
|
|
195
196
|
route "GET /w/:slug/api/tree" requires auth
|
|
196
|
-
|
|
197
|
+
let t be tree.tree(6)
|
|
198
|
+
-- ruta real del proyecto (lampson.ps1/.sh exportan LAMPSON_WORKSPACE): «copiar ruta absoluta» en el explorador
|
|
199
|
+
set t["root"] to env("LAMPSON_WORKSPACE", "")
|
|
200
|
+
give t
|
|
197
201
|
|
|
198
202
|
-- Terminales del navegador. El pty NO vive en este handler: vive en un agente supervisor
|
|
199
203
|
-- (lib/term.syn), y este socket es un puente por el bus. Así la shell sobrevive a un F5 —
|
|
@@ -261,7 +265,8 @@ serve on 8080
|
|
|
261
265
|
give git.status()
|
|
262
266
|
|
|
263
267
|
route "GET /w/:slug/api/memory" requires auth
|
|
264
|
-
|
|
268
|
+
-- ok=false: el link memory/ del workspace está roto (la UI lo dice; el agente ya lo sabe por el prompt)
|
|
269
|
+
give {"dir": memo.dir(), "ok": memo.available(), "notes": memo.list()}
|
|
265
270
|
|
|
266
271
|
route "GET /w/:slug/api/memory/note" requires auth
|
|
267
272
|
when not contains(query, "name")
|
|
@@ -334,34 +339,34 @@ serve on 8080
|
|
|
334
339
|
recover err
|
|
335
340
|
give fail(400, text(err))
|
|
336
341
|
|
|
337
|
-
--
|
|
338
|
-
route "GET /w/:slug/api/
|
|
339
|
-
give {"
|
|
342
|
+
-- plugins (tools propias): listar y encender/apagar desde la barra superior de la UI
|
|
343
|
+
route "GET /w/:slug/api/plugins" requires auth
|
|
344
|
+
give {"plugins": plugins.summary(), "global": plugins.GLOBAL_DIR, "project": plugins.PROJECT_DIR}
|
|
340
345
|
|
|
341
|
-
-- el usuario corre una tool de
|
|
342
|
-
route "POST /w/:slug/api/
|
|
346
|
+
-- el usuario corre una tool de un plugin encendido él mismo (sin pasar por el modelo)
|
|
347
|
+
route "POST /w/:slug/api/plugins/run" requires auth
|
|
343
348
|
expect body {tool: text}
|
|
344
349
|
let b be json of request
|
|
345
350
|
let args be when contains(b, "args") then b["args"] otherwise {}
|
|
346
351
|
try
|
|
347
|
-
give {"ok": true, "output":
|
|
352
|
+
give {"ok": true, "output": plugins.call(text(b["tool"]), args)}
|
|
348
353
|
recover err
|
|
349
354
|
give fail(400, text(err))
|
|
350
355
|
|
|
351
|
-
route "POST /w/:slug/api/
|
|
356
|
+
route "POST /w/:slug/api/plugins/remove" requires auth
|
|
352
357
|
expect body {name: text}
|
|
353
358
|
let b be json of request
|
|
354
359
|
try
|
|
355
|
-
give {"ok": true, "result":
|
|
360
|
+
give {"ok": true, "result": plugins.remove(text(b["name"])), "plugins": plugins.summary()}
|
|
356
361
|
recover err
|
|
357
362
|
give fail(400, text(err))
|
|
358
363
|
|
|
359
|
-
route "POST /w/:slug/api/
|
|
364
|
+
route "POST /w/:slug/api/plugins/toggle" requires auth
|
|
360
365
|
expect body {name: text}
|
|
361
366
|
let b be json of request
|
|
362
367
|
let on be contains(b, "enabled") and b["enabled"] == true
|
|
363
368
|
try
|
|
364
|
-
give {"ok": true, "result":
|
|
369
|
+
give {"ok": true, "result": plugins.set_enabled(text(b["name"]), on), "plugins": plugins.summary()}
|
|
365
370
|
recover err
|
|
366
371
|
give fail(400, text(err))
|
|
367
372
|
|
|
@@ -439,6 +444,16 @@ serve on 8080
|
|
|
439
444
|
give fail(400, "path required")
|
|
440
445
|
give tree.file_content(query.path)
|
|
441
446
|
|
|
447
|
+
-- explorador: nuevo archivo/carpeta, renombrar, mover, copiar/duplicar, borrar (lib/fs.syn; solo dentro
|
|
448
|
+
-- del workspace, nunca pisa un destino). {op, path, name?, to?, content?} → {ok, result: {path, is_dir}}
|
|
449
|
+
route "POST /w/:slug/api/fs" requires auth
|
|
450
|
+
expect body {op: text, path: text}
|
|
451
|
+
let b be json of request
|
|
452
|
+
try
|
|
453
|
+
give {"ok": true, "result": fs.apply(b)}
|
|
454
|
+
recover err
|
|
455
|
+
give fail(400, text(err))
|
|
456
|
+
|
|
442
457
|
-- configuración general (rueda de la UI): zona horaria, URL pública, webhook… → .lampson/config.json
|
|
443
458
|
route "GET /w/:slug/api/settings/values" requires auth
|
|
444
459
|
give {"values": settings.values(), "tz_detected": schedule.tz_offset()}
|
|
@@ -478,7 +493,7 @@ serve on 8080
|
|
|
478
493
|
route "GET /w/:slug/api/schedules" requires auth
|
|
479
494
|
let age be schedule.daemon_age()
|
|
480
495
|
-- este proceso tiene su propio tick (cron_every): las tareas corren mientras la web esté abierta
|
|
481
|
-
give {"tasks": schedule.summary(), "tick": schedule.TICK_SECONDS, "alive": true, "heartbeat_age": age, "mount_ok": schedule.mount_ok(), "workspace": env("LAMPSON_WORKSPACE", ""), "tz": schedule.tz_offset(), "
|
|
496
|
+
give {"tasks": schedule.summary(), "tick": schedule.TICK_SECONDS, "alive": true, "heartbeat_age": age, "mount_ok": schedule.mount_ok(), "workspace": env("LAMPSON_WORKSPACE", ""), "tz": schedule.tz_offset(), "plugins": plugins.summary()}
|
|
482
497
|
|
|
483
498
|
route "POST /w/:slug/api/schedules/add" requires auth
|
|
484
499
|
let b be json of request
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
-- lamps/example-hello/lamp.syn — la lámpara de ejemplo
|
|
2
|
-
--
|
|
3
|
-
-- Lampson la corre así por cada llamada: synsema run --cap-set stdout,time,env=LAMP_* lamp.syn
|
|
4
|
-
-- El techo (--cap-set) sale del manifiesto (lamp.json → "caps") que el humano aprobó al encenderla;
|
|
5
|
-
-- pedir más acá (p. ej. `require net`) falla con "above the host ceiling".
|
|
6
|
-
-- Entrada por env: LAMP_TOOL (qué tool), LAMP_ARGS (sus args en JSON), LAMP_DIR, LAMP_WORKSPACE.
|
|
7
|
-
-- Salida: lo que imprimas por stdout vuelve al modelo como resultado de la tool.
|
|
8
|
-
intent: "example lamp for lampson: greet"
|
|
9
|
-
|
|
10
|
-
require env("LAMP_*")
|
|
11
|
-
|
|
12
|
-
let tool be env("LAMP_TOOL", "")
|
|
13
|
-
let args be json_decode(env("LAMP_ARGS", "{}"))
|
|
14
|
-
|
|
15
|
-
when tool == "greet"
|
|
16
|
-
let who be when contains(args, "who") then text(args["who"]) otherwise "world"
|
|
17
|
-
print("hello, " + who + "! (from the example-hello lamp, running under a capability ceiling)")
|
|
18
|
-
otherwise
|
|
19
|
-
print("ERROR: unknown tool '" + tool + "'")
|
package/lib/lamps.syn
DELETED
|
@@ -1,386 +0,0 @@
|
|
|
1
|
-
-- lib/lamps.syn — "lámparas": plugins de tools que el usuario (o el agente) escribe y el humano ENCIENDE
|
|
2
|
-
--
|
|
3
|
-
-- Qué es una lámpara: una carpeta con un manifiesto `lamp.json` y el código que implementa sus tools.
|
|
4
|
-
-- lamps/<nombre>/ GLOBAL (lampson se instala una vez → vale para todos los proyectos)
|
|
5
|
-
-- workspace/.lampson/lamps/<nombre>/ del PROYECTO (el agente puede crearlas con write: es su workspace)
|
|
6
|
-
-- lamp.json = {"name", "description", "kind": "syn" | "exec",
|
|
7
|
-
-- "entry": "lamp.syn" (kind=syn: un programa Synsema)
|
|
8
|
-
-- "command": "python lamp.py" (kind=exec: cualquier ejecutable — js, py, sh…)
|
|
9
|
-
-- "caps": "file.read=workspace/*" (kind=syn, opcional: techo EXTRA sobre stdout,time,env=LAMP_*)
|
|
10
|
-
-- "timeout": 60,
|
|
11
|
-
-- "tools": [{"name", "description", "parameters": {JSON Schema}, "readonly": false}]}
|
|
12
|
-
--
|
|
13
|
-
-- Cómo corre (patrón "safe runner" de la doc 22-sandbox): cada llamada es UN proceso hijo que termina.
|
|
14
|
-
-- kind=syn → `synsema run --cap-set stdout,time,env=LAMP_*[,caps] <entry>`: el techo lo fija el
|
|
15
|
-
-- manifiesto que el humano aprobó al encenderla; un `require` de más en el código de la
|
|
16
|
-
-- lámpara falla con "above the host ceiling" — no hay forma de escalar desde adentro.
|
|
17
|
-
-- kind=exec → el comando tal cual (sin techo de lenguaje: por eso encender es SIEMPRE humano).
|
|
18
|
-
-- La tool y sus args viajan por env: LAMP_TOOL, LAMP_ARGS (JSON), LAMP_DIR (carpeta de la lámpara,
|
|
19
|
-
-- relativa a la raíz de lampson = cwd del hijo si kind=syn; si kind=exec el cwd es el workspace),
|
|
20
|
-
-- LAMP_WORKSPACE (ruta real del proyecto). La lámpara imprime el resultado (texto o JSON) por stdout.
|
|
21
|
-
--
|
|
22
|
-
-- Encendido: `.lampson/lamps.json` {"enabled": {"<nombre>": true}}. APAGADA por defecto: descubrir una
|
|
23
|
-
-- carpeta nueva no la activa; la enciende el humano (UI: barra superior; terminal: /lamps on <nombre>) o el
|
|
24
|
-
-- agente con la tool `lamp` (action=enable → ask SIEMPRE, como skill install / mcp add). Crear = write;
|
|
25
|
-
-- encender = permiso. Sin `use` dinámico (Synsema no lo tiene, a propósito): nada de esto entra al proceso
|
|
26
|
-
-- de lampson, solo corre como hijo.
|
|
27
|
-
--
|
|
28
|
-
-- Las tools encendidas entran al catálogo como `lamp_<lámpara>_<tool>`; loop.execute las despacha acá
|
|
29
|
-
-- (registry las marca con "lamp", como "mcp"). permission.syn: ask por defecto, yolo permite, strict deniega;
|
|
30
|
-
-- las readonly también en plan/review/explore.
|
|
31
|
-
|
|
32
|
-
use "./tools/common.syn" as c
|
|
33
|
-
|
|
34
|
-
export let GLOBAL_DIR be "lamps"
|
|
35
|
-
export let PROJECT_DIR be "workspace/.lampson/lamps"
|
|
36
|
-
export let STATE_FILE be ".lampson/lamps.json"
|
|
37
|
-
let DEFAULT_TIMEOUT be 60
|
|
38
|
-
let BASE_CAPS be "stdout,time,env=LAMP_*"
|
|
39
|
-
|
|
40
|
-
-- nombre de lámpara: SIN "_" (el separador de lamp_<lámpara>_<tool>); las tools sí pueden llevarlo
|
|
41
|
-
task valid_name(name)
|
|
42
|
-
when name == nothing or name == ""
|
|
43
|
-
give false
|
|
44
|
-
give matches(text(name), "[a-zA-Z0-9-]{1,32}")
|
|
45
|
-
|
|
46
|
-
task valid_tool_name(name)
|
|
47
|
-
when name == nothing or name == ""
|
|
48
|
-
give false
|
|
49
|
-
give matches(text(name), "[a-zA-Z0-9_-]{1,40}")
|
|
50
|
-
|
|
51
|
-
-- lee y valida un manifiesto; devuelve {ok, lamp} o {ok: false, error}
|
|
52
|
-
task read_manifest(dir, name, scope)
|
|
53
|
-
let path be dir + "/" + name + "/lamp.json"
|
|
54
|
-
let doc be nothing
|
|
55
|
-
try
|
|
56
|
-
set doc to json_decode(read_file(path))
|
|
57
|
-
recover err
|
|
58
|
-
give {"ok": false, "name": name, "scope": scope, "error": "no lamp.json: " + text(err)}
|
|
59
|
-
let kind be when contains(doc, "kind") then lower(text(doc["kind"])) otherwise "syn"
|
|
60
|
-
when kind != "syn" and kind != "exec"
|
|
61
|
-
give {"ok": false, "name": name, "scope": scope, "error": "kind must be syn or exec"}
|
|
62
|
-
when kind == "syn" and not contains(doc, "entry")
|
|
63
|
-
give {"ok": false, "name": name, "scope": scope, "error": "kind=syn needs \"entry\" (the .syn file)"}
|
|
64
|
-
when kind == "exec" and not contains(doc, "command")
|
|
65
|
-
give {"ok": false, "name": name, "scope": scope, "error": "kind=exec needs \"command\""}
|
|
66
|
-
when not contains(doc, "tools") or length(doc["tools"]) == 0
|
|
67
|
-
give {"ok": false, "name": name, "scope": scope, "error": "no tools declared"}
|
|
68
|
-
let tools be []
|
|
69
|
-
each t in doc["tools"]
|
|
70
|
-
when not contains(t, "name") or not valid_tool_name(t["name"])
|
|
71
|
-
give {"ok": false, "name": name, "scope": scope, "error": "a tool has no valid name"}
|
|
72
|
-
set tools to append(tools, {"name": text(t["name"]), "description": when contains(t, "description") then text(t["description"]) otherwise "", "parameters": when contains(t, "parameters") then t["parameters"] otherwise {"type": "object", "properties": {}}, "readonly": contains(t, "readonly") and t["readonly"] == true})
|
|
73
|
-
give {"ok": true, "name": name, "scope": scope, "lamp": {"name": name, "scope": scope, "dir": dir + "/" + name, "kind": kind, "description": when contains(doc, "description") then text(doc["description"]) otherwise "", "entry": when contains(doc, "entry") then text(doc["entry"]) otherwise "", "command": when contains(doc, "command") then text(doc["command"]) otherwise "", "caps": when contains(doc, "caps") then text(doc["caps"]) otherwise "", "timeout": when contains(doc, "timeout") then floor(number(doc["timeout"])) otherwise DEFAULT_TIMEOUT, "tools": tools}}
|
|
74
|
-
|
|
75
|
-
task scan(dir, scope)
|
|
76
|
-
let out be []
|
|
77
|
-
try
|
|
78
|
-
each e in list_dir(dir)
|
|
79
|
-
when e["is_dir"] and valid_name(e["name"])
|
|
80
|
-
set out to append(out, read_manifest(dir, e["name"], scope))
|
|
81
|
-
recover err
|
|
82
|
-
give out
|
|
83
|
-
give out
|
|
84
|
-
|
|
85
|
-
task load_state()
|
|
86
|
-
try
|
|
87
|
-
let doc be json_decode(read_file(STATE_FILE))
|
|
88
|
-
give when contains(doc, "enabled") then doc["enabled"] otherwise {}
|
|
89
|
-
recover err
|
|
90
|
-
give {}
|
|
91
|
-
|
|
92
|
-
-- todas las lámparas descubiertas (proyecto pisa a global por nombre), con enabled y error.
|
|
93
|
-
-- LAMPSON_LAMPS_DIR = una carpeta extra (tests).
|
|
94
|
-
export task all()
|
|
95
|
-
require file(".lampson")
|
|
96
|
-
require file(".lampson/*")
|
|
97
|
-
require file.read("lamps")
|
|
98
|
-
require file.read("lamps/*")
|
|
99
|
-
require file("workspace")
|
|
100
|
-
require file("workspace/*")
|
|
101
|
-
require env("LAMPSON_*")
|
|
102
|
-
let by_name be {}
|
|
103
|
-
each r in scan(GLOBAL_DIR, "global")
|
|
104
|
-
set by_name[r["name"]] to r
|
|
105
|
-
each r in scan(PROJECT_DIR, "project")
|
|
106
|
-
set by_name[r["name"]] to r
|
|
107
|
-
when env("LAMPSON_LAMPS_DIR", "") != ""
|
|
108
|
-
each r in scan(env("LAMPSON_LAMPS_DIR", ""), "extra")
|
|
109
|
-
set by_name[r["name"]] to r
|
|
110
|
-
let st be load_state()
|
|
111
|
-
let out be []
|
|
112
|
-
each n in sort_by(keys(by_name), (x) => x)
|
|
113
|
-
let r be by_name[n]
|
|
114
|
-
let on be contains(st, n) and st[n] == true
|
|
115
|
-
when r["ok"]
|
|
116
|
-
let l be r["lamp"]
|
|
117
|
-
set l["enabled"] to on
|
|
118
|
-
set l["error"] to nothing
|
|
119
|
-
set out to append(out, l)
|
|
120
|
-
otherwise
|
|
121
|
-
set out to append(out, {"name": n, "scope": r["scope"], "dir": "", "kind": "?", "description": "", "entry": "", "command": "", "caps": "", "timeout": 0, "tools": [], "enabled": false, "error": r["error"]})
|
|
122
|
-
give out
|
|
123
|
-
|
|
124
|
-
export task enabled()
|
|
125
|
-
require file(".lampson")
|
|
126
|
-
require file(".lampson/*")
|
|
127
|
-
require file.read("lamps")
|
|
128
|
-
require file.read("lamps/*")
|
|
129
|
-
require file("workspace")
|
|
130
|
-
require file("workspace/*")
|
|
131
|
-
require env("LAMPSON_*")
|
|
132
|
-
give where(all(), (l) => l["enabled"] and l["error"] == nothing)
|
|
133
|
-
|
|
134
|
-
-- encender/apagar (el humano, o el agente tras aprobación). Encender una lámpara rota es error.
|
|
135
|
-
export task set_enabled(name, on)
|
|
136
|
-
require file(".lampson")
|
|
137
|
-
require file(".lampson/*")
|
|
138
|
-
require file.read("lamps")
|
|
139
|
-
require file.read("lamps/*")
|
|
140
|
-
require file("workspace")
|
|
141
|
-
require file("workspace/*")
|
|
142
|
-
require env("LAMPSON_*")
|
|
143
|
-
when not valid_name(name)
|
|
144
|
-
raise("invalid lamp name")
|
|
145
|
-
let found be where(all(), (l) => l["name"] == name)
|
|
146
|
-
when length(found) == 0
|
|
147
|
-
raise("no lamp named '" + name + "' (global: " + GLOBAL_DIR + "/<name>/lamp.json · project: " + PROJECT_DIR + "/<name>/lamp.json)")
|
|
148
|
-
when on and found[0]["error"] != nothing
|
|
149
|
-
raise("lamp '" + name + "' is broken: " + text(found[0]["error"]))
|
|
150
|
-
let st be load_state()
|
|
151
|
-
set st[name] to on == true
|
|
152
|
-
write_file(STATE_FILE, json_encode({"enabled": st}))
|
|
153
|
-
let l be found[0]
|
|
154
|
-
when on
|
|
155
|
-
give "lamp '" + name + "' ON (" + l["kind"] + ", " + text(length(l["tools"])) + " tools" + (when l["kind"] == "syn" then ", ceiling: " + ceiling_of(l) otherwise ", NO capability ceiling: exec") + ") — its tools are in the catalog from the next turn as lamp_" + name + "_<tool>"
|
|
156
|
-
give "lamp '" + name + "' OFF"
|
|
157
|
-
|
|
158
|
-
task ceiling_of(l)
|
|
159
|
-
give when l["caps"] == "" then BASE_CAPS otherwise BASE_CAPS + "," + l["caps"]
|
|
160
|
-
|
|
161
|
-
-- ---------- catálogo ----------
|
|
162
|
-
export task tool_name(lamp, tool)
|
|
163
|
-
give "lamp_" + lamp + "_" + tool
|
|
164
|
-
|
|
165
|
-
task split_name(full)
|
|
166
|
-
let segs be split(slice(full, 5, length(full)), "_")
|
|
167
|
-
when length(segs) < 2
|
|
168
|
-
raise("bad lamp tool name '" + full + "'")
|
|
169
|
-
give {"lamp": segs[0], "tool": join(slice(segs, 1, length(segs)), "_")}
|
|
170
|
-
|
|
171
|
-
export task catalog(readonly_only)
|
|
172
|
-
require file(".lampson")
|
|
173
|
-
require file(".lampson/*")
|
|
174
|
-
require file.read("lamps")
|
|
175
|
-
require file.read("lamps/*")
|
|
176
|
-
require file("workspace")
|
|
177
|
-
require file("workspace/*")
|
|
178
|
-
require env("LAMPSON_*")
|
|
179
|
-
let out be []
|
|
180
|
-
each l in enabled()
|
|
181
|
-
each t in l["tools"]
|
|
182
|
-
when not readonly_only or t["readonly"]
|
|
183
|
-
set out to append(out, {"name": tool_name(l["name"], t["name"]), "description": "[lamp " + l["name"] + "] " + t["description"], "parameters": t["parameters"]})
|
|
184
|
-
give out
|
|
185
|
-
|
|
186
|
-
export task names(readonly_only)
|
|
187
|
-
require file(".lampson")
|
|
188
|
-
require file(".lampson/*")
|
|
189
|
-
require file.read("lamps")
|
|
190
|
-
require file.read("lamps/*")
|
|
191
|
-
require file("workspace")
|
|
192
|
-
require file("workspace/*")
|
|
193
|
-
require env("LAMPSON_*")
|
|
194
|
-
give apply(catalog(readonly_only), (s) => s["name"])
|
|
195
|
-
|
|
196
|
-
-- ---------- llamada: un proceso hijo por invocación ----------
|
|
197
|
-
task run_lamp(exe, argv, cwd, envs, t)
|
|
198
|
-
let p be proc_spawn(exe, argv, {"cwd": cwd, "env": envs, "stderr": "merge", "on_full": "drop_oldest"})
|
|
199
|
-
proc_close_stdin(p)
|
|
200
|
-
let deadline be now() + t
|
|
201
|
-
let lines be []
|
|
202
|
-
let code be nothing
|
|
203
|
-
let timed_out be false
|
|
204
|
-
while code == nothing and not timed_out
|
|
205
|
-
let left be deadline - now()
|
|
206
|
-
when left <= 0
|
|
207
|
-
set timed_out to true
|
|
208
|
-
otherwise
|
|
209
|
-
let ev be proc_recv(p, when left < 0.2 then 0.2 otherwise left)
|
|
210
|
-
when ev == nothing
|
|
211
|
-
set timed_out to now() >= deadline
|
|
212
|
-
otherwise when ev["type"] == "exit"
|
|
213
|
-
set code to ev["data"]["exit_code"]
|
|
214
|
-
otherwise
|
|
215
|
-
set lines to append(lines, ev["data"])
|
|
216
|
-
proc_close(p)
|
|
217
|
-
let out be join(lines, "\n")
|
|
218
|
-
when timed_out
|
|
219
|
-
give "ERROR: lamp timed out after " + text(t) + "s (killed). Output so far:\n" + out
|
|
220
|
-
when code != 0
|
|
221
|
-
give "ERROR: lamp exited with code " + text(code) + "\n" + out
|
|
222
|
-
give when trim(out) == "" then "(no output)" otherwise out
|
|
223
|
-
|
|
224
|
-
-- separa "python lamp.py --x" en exe + args (comillas simples/dobles respetadas)
|
|
225
|
-
export task split_command(line)
|
|
226
|
-
let toks be []
|
|
227
|
-
let cur be ""
|
|
228
|
-
let q be ""
|
|
229
|
-
let has be false
|
|
230
|
-
let i be 0
|
|
231
|
-
while i < length(line)
|
|
232
|
-
let ch be slice(line, i, i + 1)
|
|
233
|
-
set i to i + 1
|
|
234
|
-
when q != ""
|
|
235
|
-
when ch == q
|
|
236
|
-
set q to ""
|
|
237
|
-
otherwise
|
|
238
|
-
set cur to cur + ch
|
|
239
|
-
otherwise when ch == "\"" or ch == "'"
|
|
240
|
-
set q to ch
|
|
241
|
-
set has to true
|
|
242
|
-
otherwise when ch == " " or ch == "\t"
|
|
243
|
-
when cur != "" or has
|
|
244
|
-
set toks to append(toks, cur)
|
|
245
|
-
set cur to ""
|
|
246
|
-
set has to false
|
|
247
|
-
otherwise
|
|
248
|
-
set cur to cur + ch
|
|
249
|
-
when cur != "" or has
|
|
250
|
-
set toks to append(toks, cur)
|
|
251
|
-
give toks
|
|
252
|
-
|
|
253
|
-
export task call(full_name, args)
|
|
254
|
-
require exec
|
|
255
|
-
require time
|
|
256
|
-
require file(".lampson")
|
|
257
|
-
require file(".lampson/*")
|
|
258
|
-
require file.read("lamps")
|
|
259
|
-
require file.read("lamps/*")
|
|
260
|
-
require file("workspace")
|
|
261
|
-
require file("workspace/*")
|
|
262
|
-
require env("LAMPSON_*")
|
|
263
|
-
let parts be split_name(full_name)
|
|
264
|
-
let found be where(enabled(), (l) => l["name"] == parts["lamp"])
|
|
265
|
-
when length(found) == 0
|
|
266
|
-
give "ERROR: lamp '" + parts["lamp"] + "' is not enabled (the user turns lamps on)"
|
|
267
|
-
let l be found[0]
|
|
268
|
-
let tl be where(l["tools"], (t) => t["name"] == parts["tool"])
|
|
269
|
-
when length(tl) == 0
|
|
270
|
-
give "ERROR: lamp '" + l["name"] + "' has no tool '" + parts["tool"] + "'"
|
|
271
|
-
-- LAMP_DIR relativa a la raíz de lampson (cwd del hijo para kind=syn); LAMP_WORKSPACE = la ruta real del
|
|
272
|
-
-- proyecto (lampson.ps1 la exporta; sin ella, la junction)
|
|
273
|
-
let envs be {"LAMP_TOOL": parts["tool"], "LAMP_ARGS": json_encode(when args == nothing then {} otherwise args), "LAMP_DIR": l["dir"], "LAMP_WORKSPACE": env("LAMPSON_WORKSPACE", c.ROOT)}
|
|
274
|
-
when l["kind"] == "syn"
|
|
275
|
-
-- techo del manifiesto: el código de la lámpara no puede pedir más de lo que el humano aprobó
|
|
276
|
-
give c.truncate(run_lamp(env("LAMPSON_SYNSEMA", "synsema"), ["run", "--cap-set", ceiling_of(l), l["dir"] + "/" + l["entry"]], ".", envs, l["timeout"]), c.MAX_OUTPUT)
|
|
277
|
-
let argv be split_command(l["command"])
|
|
278
|
-
when length(argv) == 0
|
|
279
|
-
give "ERROR: lamp '" + l["name"] + "' has an empty command"
|
|
280
|
-
-- el comando corre desde la RAÍZ DEL WORKSPACE (para que "python lamp.py" vea el proyecto), así que un
|
|
281
|
-
-- token que nombra un archivo de la carpeta de la lámpara se resuelve solo (2026-08-28: "python lamp.py"
|
|
282
|
-
-- salía con code 2 porque lamp.py no estaba en el cwd)
|
|
283
|
-
let rel_dir be when starts_with(l["dir"], c.ROOT + "/") then slice(l["dir"], length(c.ROOT) + 1, length(l["dir"])) otherwise "../" + l["dir"]
|
|
284
|
-
let resolved be []
|
|
285
|
-
each tok in argv
|
|
286
|
-
when not contains(tok, "/") and not contains(tok, "\\") and file_exists(l["dir"] + "/" + tok)
|
|
287
|
-
set resolved to append(resolved, rel_dir + "/" + tok)
|
|
288
|
-
otherwise
|
|
289
|
-
set resolved to append(resolved, tok)
|
|
290
|
-
give c.truncate(run_lamp(resolved[0], slice(resolved, 1, length(resolved)), c.ROOT, envs, l["timeout"]), c.MAX_OUTPUT)
|
|
291
|
-
|
|
292
|
-
-- crear una lámpara DEL PROYECTO (workspace/.lampson/lamps/<name>/): escribe lamp.json + el código, revalida el
|
|
293
|
-
-- manifiesto y, si es syn, corre `synsema check` sobre el entry. Como cordis_define en dsh: define y valida,
|
|
294
|
-
-- NO ejecuta ni enciende — encender sigue siendo del humano (set_enabled tras ask).
|
|
295
|
-
-- `files` = {"lamp.syn": "…", "helper.py": "…"}: nombres simples (sin / ni ..) dentro de la carpeta.
|
|
296
|
-
export task create(name, manifest, files)
|
|
297
|
-
require exec
|
|
298
|
-
require time
|
|
299
|
-
require file(".lampson")
|
|
300
|
-
require file(".lampson/*")
|
|
301
|
-
require file.read("lamps")
|
|
302
|
-
require file.read("lamps/*")
|
|
303
|
-
require file("workspace")
|
|
304
|
-
require file("workspace/*")
|
|
305
|
-
require env("LAMPSON_*")
|
|
306
|
-
when not valid_name(name)
|
|
307
|
-
raise("invalid lamp name '" + text(name) + "': letters, digits and - only (no _), max 32")
|
|
308
|
-
when manifest == nothing or not contains(manifest, "tools")
|
|
309
|
-
raise("manifest needs at least \"tools\" (and \"entry\" for kind=syn or \"command\" for kind=exec)")
|
|
310
|
-
let dir be PROJECT_DIR + "/" + name
|
|
311
|
-
let m be manifest
|
|
312
|
-
set m["name"] to name
|
|
313
|
-
when not contains(m, "kind")
|
|
314
|
-
set m["kind"] to "syn"
|
|
315
|
-
write_file(dir + "/lamp.json", json_encode(m))
|
|
316
|
-
let written be ["lamp.json"]
|
|
317
|
-
when files != nothing
|
|
318
|
-
each fname in keys(files)
|
|
319
|
-
when not matches(fname, "[A-Za-z0-9_.-]{1,64}") or starts_with(fname, ".")
|
|
320
|
-
raise("bad file name '" + fname + "' (simple names inside the lamp folder only)")
|
|
321
|
-
write_file(dir + "/" + fname, text(files[fname]))
|
|
322
|
-
set written to append(written, fname)
|
|
323
|
-
let found be where(all(), (l) => l["name"] == name)
|
|
324
|
-
when length(found) == 0
|
|
325
|
-
raise("lamp written but not discovered (?)")
|
|
326
|
-
let l be found[0]
|
|
327
|
-
when l["error"] != nothing
|
|
328
|
-
give "lamp '" + name + "' written to " + dir + " but its manifest is INVALID: " + text(l["error"]) + " — fix it with edit and call lamp(action=list) to re-check"
|
|
329
|
-
let note be ""
|
|
330
|
-
when l["kind"] == "syn"
|
|
331
|
-
let chk be run_lamp(env("LAMPSON_SYNSEMA", "synsema"), ["check", l["dir"] + "/" + l["entry"]], ".", {}, 30)
|
|
332
|
-
when starts_with(chk, "ERROR")
|
|
333
|
-
give "lamp '" + name + "' written to " + dir + " but `synsema check " + l["entry"] + "` FAILED:\n" + chk + "\nFix the code with edit; it stays off until it checks and the user turns it on."
|
|
334
|
-
set note to " · synsema check OK · ceiling when on: " + ceiling_of(l)
|
|
335
|
-
when l["kind"] == "exec"
|
|
336
|
-
set note to " · exec (no capability ceiling: the user will see that when approving)"
|
|
337
|
-
give "lamp '" + name + "' created (project, " + l["kind"] + ", " + text(length(l["tools"])) + " tools: " + join(apply(l["tools"], (t) => t["name"]), ", ") + ") in " + dir + " [" + join(written, ", ") + "]" + note + (when l["kind"] == "exec" then ". The command runs from the workspace root; a file of the lamp folder named in the command is resolved automatically" otherwise "") + ". It is OFF until the user turns it on: offer to do it (lamp action=enable asks them), or point them to the lamp switch of their UI. Its tools will be lamp_" + name + "_<tool>."
|
|
338
|
-
|
|
339
|
-
-- eliminar una lámpara DEL PROYECTO: borra su carpeta (no hay delete_file en el runtime → rm -rf por el shell
|
|
340
|
-
-- de bash.syn) y la saca del estado. Las globales (lamps/) se borran a mano: son de la instalación, no del repo.
|
|
341
|
-
export task remove(name)
|
|
342
|
-
require exec
|
|
343
|
-
require time
|
|
344
|
-
require env("LAMPSON_*")
|
|
345
|
-
require env("OS")
|
|
346
|
-
require file(".lampson")
|
|
347
|
-
require file(".lampson/*")
|
|
348
|
-
require file.read("lamps")
|
|
349
|
-
require file.read("lamps/*")
|
|
350
|
-
require file("workspace")
|
|
351
|
-
require file("workspace/*")
|
|
352
|
-
when not valid_name(name)
|
|
353
|
-
raise("invalid lamp name")
|
|
354
|
-
let found be where(all(), (l) => l["name"] == name)
|
|
355
|
-
when length(found) == 0
|
|
356
|
-
raise("no lamp named '" + name + "'")
|
|
357
|
-
let l be found[0]
|
|
358
|
-
when l["scope"] != "project"
|
|
359
|
-
raise("'" + name + "' is a " + l["scope"] + " lamp (" + GLOBAL_DIR + "/" + name + "): delete that folder by hand")
|
|
360
|
-
let dir be PROJECT_DIR + "/" + name
|
|
361
|
-
let is_win be env("OS", "") == "Windows_NT"
|
|
362
|
-
let sh be env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
|
|
363
|
-
let r be run(sh, ["-c", "rm -rf '" + dir + "'"], 30, {"cwd": "."})
|
|
364
|
-
when file_exists(dir + "/lamp.json")
|
|
365
|
-
raise("could not delete " + dir + ": " + text(r["stderr"]))
|
|
366
|
-
let st be load_state()
|
|
367
|
-
let clean be {}
|
|
368
|
-
each k in keys(st)
|
|
369
|
-
when k != name
|
|
370
|
-
set clean[k] to st[k]
|
|
371
|
-
write_file(STATE_FILE, json_encode({"enabled": clean}))
|
|
372
|
-
give "lamp '" + name + "' deleted (" + dir + ")"
|
|
373
|
-
|
|
374
|
-
-- resumen para UI/terminal/tool
|
|
375
|
-
export task summary()
|
|
376
|
-
require file(".lampson")
|
|
377
|
-
require file(".lampson/*")
|
|
378
|
-
require file.read("lamps")
|
|
379
|
-
require file.read("lamps/*")
|
|
380
|
-
require file("workspace")
|
|
381
|
-
require file("workspace/*")
|
|
382
|
-
require env("LAMPSON_*")
|
|
383
|
-
let out be []
|
|
384
|
-
each l in all()
|
|
385
|
-
set out to append(out, {"name": l["name"], "scope": l["scope"], "kind": l["kind"], "description": l["description"], "enabled": l["enabled"], "error": l["error"], "caps": when l["kind"] == "syn" and l["error"] == nothing then ceiling_of(l) otherwise "", "command": when l["kind"] == "exec" then l["command"] otherwise "", "tools": apply(l["tools"], (t) => t["name"]), "tool_specs": l["tools"], "dir": l["dir"]})
|
|
386
|
-
give out
|