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/lib/loop.syn CHANGED
@@ -23,7 +23,7 @@
23
23
  use "./provider.syn" as provider
24
24
  use "./permission.syn" as permission
25
25
  use "./mcp.syn" as mcp
26
- use "./lamps.syn" as lamps
26
+ use "./plugins.syn" as plugins
27
27
  use "./tools/todo.syn" as todo
28
28
 
29
29
  let MAX_ERRORS_PER_TURN be 8
@@ -47,7 +47,7 @@ let REPEAT_HARD be 8
47
47
  -- seguidas sin ninguna acción (edit/write/bash/process/delegate/mcp). Visto 2026-08-28 con deepseek-v4-pro:
48
48
  -- 39 read/ls/find seguidos, 0 ediciones, 419k tokens, dos veces, con las reglas de prompt ignoradas.
49
49
  -- A la mitad del tope el resultado lleva un aviso; al tope, read/ls/find/grep se rechazan hasta que actúe.
50
- let READ_ONLY_TOOLS be ["read", "ls", "find", "grep"]
50
+ let READ_ONLY_TOOLS be ["read", "ls", "find", "grep", "fetch"]
51
51
  let ACTION_TOOLS be ["edit", "write", "bash", "process", "delegate"]
52
52
 
53
53
  export task explore_cap()
@@ -67,7 +67,7 @@ export task explore_verdict(streak, name, cap)
67
67
  when n >= cap
68
68
  give {"streak": n, "note": "", "refuse": true}
69
69
  when n == floor(cap / 2)
70
- give {"streak": n, "note": "\n\n[harness] " + text(n) + " read-only calls in a row without changing anything. You likely know enough: edit/write now, or run the relevant command. After " + text(cap) + " read-only calls in a row, read/ls/find/grep are refused until you act. If the codebase is genuinely too big, delegate ONE focused question to an `explore` sub-agent instead of reading everything yourself.", "refuse": false}
70
+ give {"streak": n, "note": "\n\n[harness] " + text(n) + " read-only calls in a row without changing anything. You likely know enough: edit/write now, or run the relevant command. After " + text(cap) + " read-only calls in a row, read/ls/find/grep/fetch are refused until you act. If the codebase is genuinely too big, delegate ONE focused question to an `explore` sub-agent instead of reading everything yourself.", "refuse": false}
71
71
  give {"streak": n, "note": "", "refuse": false}
72
72
 
73
73
  -- JSON canónico (claves ordenadas en profundidad): reordenar propiedades no engaña al detector
@@ -98,7 +98,8 @@ task safe_id(id, step)
98
98
  export task spill(name, id, out)
99
99
  require file(".lampson")
100
100
  require file(".lampson/*")
101
- when name == "read" or length(out) <= SPILL_CAP
101
+ -- read y fetch se recortan solas (fetch: cabeza+cola con el texto completo ya en .lampson/spill)
102
+ when name == "read" or name == "fetch" or length(out) <= SPILL_CAP
102
103
  give out
103
104
  -- el informe de los subagentes es el entregable: no se manda a disco (hasta 60k)
104
105
  when name == "delegate" and length(out) <= SPILL_CAP * 6
@@ -285,9 +286,9 @@ task execute(tc, opts, on_event)
285
286
  -- tools MCP: no son tasks Synsema (args libres); el registry las marca con "mcp"
286
287
  when registry[name] == "mcp"
287
288
  set out to text(mcp.call(name, args))
288
- -- tools de lámparas: un proceso hijo por llamada (lib/lamps.syn)
289
- otherwise when registry[name] == "lamp"
290
- set out to text(lamps.call(name, args))
289
+ -- tools de plugins: un proceso hijo por llamada (lib/plugins.syn)
290
+ otherwise when registry[name] == "plugin"
291
+ set out to text(plugins.call(name, args))
291
292
  otherwise
292
293
  set out to text(call_tool(registry[name], args))
293
294
  recover err
@@ -394,7 +395,7 @@ export task run_turn(cfg, messages, opts, on_event)
394
395
  set out to "ERROR: you already called " + tc["name"] + " with these exact arguments " + text(repeats) + " times in a row. The result will not change; try a different approach or report the problem."
395
396
  emit(on_event, "tool_denied", {"call": tc, "reason": "repeated call (" + text(repeats) + "x)"}, tag)
396
397
  otherwise when ex["refuse"]
397
- set out to "ERROR: exploration cap reached — " + text(explore_streak) + " read-only calls in a row (read/ls/find/grep) without a single change. Reading more will not help. Do one of: (1) edit/write the files you already read; (2) run a command (bash/process) that moves the task; (3) delegate ONE focused question to an `explore` sub-agent; (4) tell the user what you need. read/ls/find/grep are refused until you do."
398
+ set out to "ERROR: exploration cap reached — " + text(explore_streak) + " read-only calls in a row (read/ls/find/grep) without a single change. Reading more will not help. Do one of: (1) edit/write the files you already read; (2) run a command (bash/process) that moves the task; (3) delegate ONE focused question to an `explore` sub-agent; (4) tell the user what you need. read/ls/find/grep/fetch are refused until you do."
398
399
  emit(on_event, "tool_denied", {"call": tc, "reason": "exploration cap (" + text(explore_streak) + " read-only calls without acting)"}, tag)
399
400
  otherwise when contains(READ_ONLY_TOOLS, tc["name"]) and reads_total >= turn_cap
400
401
  set out to "ERROR: this turn already made " + text(reads_total) + " read-only calls (limit " + text(turn_cap) + "). Everything you read is in your context. Act on it now (edit/write/run), delegate ONE focused question to an `explore` sub-agent, or report to the user."
@@ -9,6 +9,8 @@
9
9
  --
10
10
  -- Modos (LAMPSON_PERMISSION): "ask" (default) | "yolo" (dangerous → allow) | "strict" (dangerous → deny)
11
11
 
12
+ use "./tools/url.syn" as u
13
+
12
14
  -- Tier 1: nunca. Coincidencia por substring, case-insensitive.
13
15
  -- (el comando se evalúa con un espacio final añadido, así "rm -rf / " matchea la raíz pero NO "rm -rf /tmp/x")
14
16
  export let HARDLINE be [
@@ -69,6 +71,27 @@ export task evaluate(name, args, mode)
69
71
  give {"decision": "deny", "reason": "strict mode (dangerous: " + danger + ")"}
70
72
  give {"decision": "ask", "reason": "dangerous pattern: " + danger}
71
73
  give {"decision": "allow", "reason": "command"}
74
+ when name == "fetch"
75
+ -- política de hosts de url.syn (hermes url_safety): secretos en la URL y metadata de la nube se
76
+ -- deniegan SIEMPRE (incluso en yolo); hosts privados/loopback (el dev server del usuario) piden;
77
+ -- lo público se permite. Una URL inválida se permite: la tool devuelve el error explicado.
78
+ let furl be when contains(args, "url") then text(args["url"]) otherwise ""
79
+ let why be u.sensitive(furl)
80
+ when why != nothing
81
+ give {"decision": "deny", "reason": "the URL carries " + why + " — secrets never travel in URLs"}
82
+ let fp be u.parse(furl)
83
+ when not fp["ok"]
84
+ give {"decision": "allow", "reason": "invalid URL (the tool explains)"}
85
+ let cls be u.host_class(fp["host"])
86
+ when cls == "blocked"
87
+ give {"decision": "deny", "reason": "cloud metadata endpoint " + fp["host"]}
88
+ when cls == "private"
89
+ when mode == "yolo"
90
+ give {"decision": "allow", "reason": "yolo mode (private host " + fp["host"] + ")"}
91
+ when mode == "strict"
92
+ give {"decision": "deny", "reason": "strict mode (private host " + fp["host"] + ")"}
93
+ give {"decision": "ask", "reason": "fetches a private/loopback host (" + fp["origin"] + ")"}
94
+ give {"decision": "allow", "reason": "public URL"}
72
95
  when name == "write" or name == "edit"
73
96
  -- el scope file("./*") ya impide salir del workspace; aquí solo miramos secretos obvios
74
97
  let p be when contains(args, "path") then lower(replace_text(text(args["path"]), "\\", "/")) otherwise ""
@@ -91,20 +114,20 @@ export task evaluate(name, args, mode)
91
114
  when mode == "strict"
92
115
  give {"decision": "deny", "reason": "strict mode (adds an LSP server)"}
93
116
  give {"decision": "ask", "reason": "configures LSP server '" + (when contains(args, "server") then text(args["server"]) otherwise "?") + "' (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ") — it will run the preset command on the first query"}
94
- when starts_with(name, "lamp_")
95
- -- tool de una lámpara (código del usuario/agente, un proceso por llamada): como las MCP
117
+ when starts_with(name, "plugin_")
118
+ -- tool de un plugin (código del usuario/agente, un proceso por llamada): como las MCP
96
119
  when mode == "yolo"
97
- give {"decision": "allow", "reason": "yolo mode (lamp tool)"}
120
+ give {"decision": "allow", "reason": "yolo mode (plugin tool)"}
98
121
  when mode == "strict"
99
- give {"decision": "deny", "reason": "strict mode (lamp tool)"}
100
- give {"decision": "ask", "reason": "lamp tool " + name}
101
- when name == "lamp"
102
- -- encender una lámpara = autorizar código a correr con las capacidades de su manifiesto: humano SIEMPRE
122
+ give {"decision": "deny", "reason": "strict mode (plugin tool)"}
123
+ give {"decision": "ask", "reason": "plugin tool " + name}
124
+ when name == "plugin"
125
+ -- encender un plugin = autorizar código a correr con las capacidades de su manifiesto: humano SIEMPRE
103
126
  let act be when contains(args, "action") then text(args["action"]) otherwise "list"
104
127
  when act == "enable" or act == "disable"
105
128
  when mode == "strict"
106
- give {"decision": "deny", "reason": "strict mode (" + act + " lamp)"}
107
- give {"decision": "ask", "reason": act + "s lamp '" + (when contains(args, "name") then text(args["name"]) otherwise "?") + "'"}
129
+ give {"decision": "deny", "reason": "strict mode (" + act + " plugin)"}
130
+ give {"decision": "ask", "reason": act + "s plugin '" + (when contains(args, "name") then text(args["name"]) otherwise "?") + "'"}
108
131
  give {"decision": "allow", "reason": "read-only tool"}
109
132
  when name == "mcp"
110
133
  -- conectar un server MCP = ejecutar un comando de terceros con env propio: humano siempre, incluso en yolo
@@ -115,7 +138,7 @@ export task evaluate(name, args, mode)
115
138
  give {"decision": "ask", "reason": act + "s MCP server '" + (when contains(args, "name") then text(args["name"]) otherwise "?") + "'" + (when act == "add" then " → runs: " + (when contains(args, "command") then text(args["command"]) otherwise "?") otherwise "")}
116
139
  give {"decision": "allow", "reason": "read-only tool"}
117
140
  when name == "schedule"
118
- -- programar = autorizar corridas futuras SIN nadie mirando (un comando, una lámpara, o el agente entero
141
+ -- programar = autorizar corridas futuras SIN nadie mirando (un comando, un plugin, o el agente entero
119
142
  -- con un sobre de permisos): humano siempre, incluso en yolo. list/log son lectura.
120
143
  let sact be when contains(args, "action") then text(args["action"]) otherwise "list"
121
144
  when sact == "list" or sact == "log"
@@ -140,15 +163,15 @@ task one_line(s, max)
140
163
  give t
141
164
 
142
165
  export task describe_call(name, args)
143
- when starts_with(name, "mcp_") or starts_with(name, "lamp_")
166
+ when starts_with(name, "mcp_") or starts_with(name, "plugin_")
144
167
  give name + " " + one_line(json_encode(args), 160)
145
168
  when name == "lsp" and contains(args, "op") and text(args["op"]) == "add"
146
169
  give "lsp add " + (when contains(args, "server") then text(args["server"]) otherwise "?") + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
147
170
  when name == "lsp"
148
171
  give "lsp " + (when contains(args, "op") then text(args["op"]) otherwise "?") + " " + (when contains(args, "path") then text(args["path"]) otherwise "?") + (when contains(args, "line") then ":" + text(args["line"]) + (when contains(args, "character") then ":" + text(args["character"]) otherwise "") otherwise "")
149
- when name == "lamp"
172
+ when name == "plugin"
150
173
  let lact be when contains(args, "action") then text(args["action"]) otherwise "list"
151
- give "lamp " + lact + (when lact != "list" then " " + (when contains(args, "name") then text(args["name"]) otherwise "?") otherwise "") + (when lact == "create" and contains(args, "files") then " [" + join(keys(args["files"]), ", ") + "]" otherwise "")
174
+ give "plugin " + lact + (when lact != "list" then " " + (when contains(args, "name") then text(args["name"]) otherwise "?") otherwise "") + (when lact == "create" and contains(args, "files") then " [" + join(keys(args["files"]), ", ") + "]" otherwise "")
152
175
  when name == "mcp"
153
176
  let act be when contains(args, "action") then text(args["action"]) otherwise "list"
154
177
  when act == "add"
@@ -160,13 +183,16 @@ export task describe_call(name, args)
160
183
  let sact be arg(args, "action", "list")
161
184
  when sact == "add"
162
185
  let what be arg(args, "kind", "?")
163
- let body be when what == "lamp" then "lamp " + arg(args, "lamp", "?") + "." + arg(args, "tool", "?") + (when contains(args, "args") then " " + one_line(json_encode(args["args"]), 80) otherwise "") otherwise (when what == "bash" then "$ " + one_line(arg(args, "command", "?"), 120) otherwise "agent " + arg(args, "agent", "build") + ": " + one_line(arg(args, "prompt", "?"), 160))
186
+ let body be when what == "plugin" then "plugin " + arg(args, "plugin", "?") + "." + arg(args, "tool", "?") + (when contains(args, "args") then " " + one_line(json_encode(args["args"]), 80) otherwise "") otherwise (when what == "bash" then "$ " + one_line(arg(args, "command", "?"), 120) otherwise "agent " + arg(args, "agent", "build") + ": " + one_line(arg(args, "prompt", "?"), 160))
164
187
  give "schedule add «" + arg(args, "name", "?") + "» · " + arg(args, "at", "?") + " · " + body + " · permission " + arg(args, "permission", "ask") + (when contains(args, "notify") then " · notify " + text(args["notify"]) otherwise "")
165
188
  give "schedule " + sact + (when contains(args, "id") then " " + text(args["id"]) otherwise "")
166
189
  when name == "skill" and is_install(args)
167
190
  give "skill install " + text(args["source"]) + " --skill " + text(args["name"]) + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
168
191
  when name == "bash"
169
192
  give "$ " + one_line(when contains(args, "command") then text(args["command"]) otherwise "", 160)
193
+ when name == "fetch"
194
+ let ffmt be arg(args, "format", "markdown")
195
+ give "fetch " + one_line(arg(args, "url", "?"), 140) + (when ffmt != "markdown" then " (" + ffmt + ")" otherwise "")
170
196
  -- lectura/búsqueda: como lo escribiría un humano en la shell
171
197
  when name == "read"
172
198
  let rng be when contains(args, "offset") or contains(args, "limit") then " (" + (when contains(args, "offset") then "desde " + text(floor(number(text(args["offset"])))) otherwise "") + (when contains(args, "limit") then " " + text(floor(number(text(args["limit"])))) + " líneas" otherwise "") + ")" otherwise ""
@@ -0,0 +1,408 @@
1
+ -- lib/plugins.syn — plugins: tools que el usuario (o el agente) escribe y el humano ENCIENDE
2
+ --
3
+ -- Qué es un plugin: una carpeta con un manifiesto `plugin.json` y el código que implementa sus tools.
4
+ -- plugins/<nombre>/ GLOBAL (lampson se instala una vez → vale para todos los proyectos)
5
+ -- workspace/.lampson/plugins/<nombre>/ del PROYECTO (el agente puede crearlos con write: es su workspace)
6
+ -- plugin.json = {"name", "description", "kind": "syn" | "exec",
7
+ -- "entry": "plugin.syn" (kind=syn: un programa Synsema)
8
+ -- "command": "python plugin.py" (kind=exec: cualquier ejecutable — js, py, sh…)
9
+ -- "caps": "file.read=workspace/*" (kind=syn, opcional: techo EXTRA sobre stdout,time,env=PLUGIN_*)
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=PLUGIN_*[,caps] <entry>`: el techo lo fija el
15
+ -- manifiesto que el humano aprobó al encenderlo; un `require` de más en el código del
16
+ -- plugin 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: PLUGIN_TOOL, PLUGIN_ARGS (JSON), PLUGIN_DIR (carpeta del plugin,
19
+ -- relativa a la raíz de lampson = cwd del hijo si kind=syn; si kind=exec el cwd es el workspace),
20
+ -- PLUGIN_WORKSPACE (ruta real del proyecto). El plugin imprime el resultado (texto o JSON) por stdout.
21
+ --
22
+ -- Encendido: `.lampson/plugins.json` {"enabled": {"<nombre>": true}}. APAGADO por defecto: descubrir una
23
+ -- carpeta nueva no lo activa; lo enciende el humano (UI: barra superior; terminal: /plugins on <nombre>) o el
24
+ -- agente con la tool `plugin` (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 `plugin_<plugin>_<tool>`; loop.execute las despacha acá
29
+ -- (registry las marca con "plugin", como "mcp"). permission.syn: ask por defecto, yolo permite, strict deniega;
30
+ -- las readonly también en plan/review/explore.
31
+ --
32
+ -- Compat (2026-09-01: antes se llamaban "lámparas"/lamps). Se siguen descubriendo las carpetas viejas
33
+ -- `.lampson/lamps/<nombre>/` (marcadas `legacy: true` en summary; la UI y /plugins lo avisan), un `plugin.json`
34
+ -- ausente cae a `lamp.json`, el estado cae a `.lampson/lamps.json` si `.lampson/plugins.json` no existe (se
35
+ -- escribe siempre el nuevo), y el hijo recibe también LAMP_TOOL/LAMP_ARGS/LAMP_DIR/LAMP_WORKSPACE (el techo
36
+ -- base incluye env=LAMP_*). Es un puente, no un formato: renombrá la carpeta y el manifiesto.
37
+
38
+ use "./tools/common.syn" as c
39
+
40
+ export let GLOBAL_DIR be "plugins"
41
+ export let PROJECT_DIR be "workspace/.lampson/plugins"
42
+ export let LEGACY_PROJECT_DIR be "workspace/.lampson/lamps"
43
+ export let STATE_FILE be ".lampson/plugins.json"
44
+ let LEGACY_STATE_FILE be ".lampson/lamps.json"
45
+ export let PREFIX be "plugin_"
46
+ export let LEGACY_HINT be "old .lampson/lamps/ folder — rename it to .lampson/plugins/ (and lamp.json to plugin.json)"
47
+ let DEFAULT_TIMEOUT be 60
48
+ let BASE_CAPS be "stdout,time,env=PLUGIN_*,env=LAMP_*"
49
+
50
+ -- nombre de plugin: SIN "_" (el separador de plugin_<plugin>_<tool>); las tools sí pueden llevarlo
51
+ task valid_name(name)
52
+ when name == nothing or name == ""
53
+ give false
54
+ give matches(text(name), "[a-zA-Z0-9-]{1,32}")
55
+
56
+ task valid_tool_name(name)
57
+ when name == nothing or name == ""
58
+ give false
59
+ give matches(text(name), "[a-zA-Z0-9_-]{1,40}")
60
+
61
+ -- lee y valida un manifiesto; devuelve {ok, plugin} o {ok: false, error}. `legacy` = viene de una carpeta vieja
62
+ -- (o de un lamp.json): se lista igual, con el aviso.
63
+ task read_manifest(dir, name, scope, legacy)
64
+ let path be dir + "/" + name + "/plugin.json"
65
+ let is_legacy be legacy
66
+ when not file_exists(path) and file_exists(dir + "/" + name + "/lamp.json")
67
+ set path to dir + "/" + name + "/lamp.json"
68
+ set is_legacy to true
69
+ let doc be nothing
70
+ try
71
+ set doc to json_decode(read_file(path))
72
+ recover err
73
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "no plugin.json: " + text(err)}
74
+ let kind be when contains(doc, "kind") then lower(text(doc["kind"])) otherwise "syn"
75
+ when kind != "syn" and kind != "exec"
76
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "kind must be syn or exec"}
77
+ when kind == "syn" and not contains(doc, "entry")
78
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "kind=syn needs \"entry\" (the .syn file)"}
79
+ when kind == "exec" and not contains(doc, "command")
80
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "kind=exec needs \"command\""}
81
+ when not contains(doc, "tools") or length(doc["tools"]) == 0
82
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "no tools declared"}
83
+ let tools be []
84
+ each t in doc["tools"]
85
+ when not contains(t, "name") or not valid_tool_name(t["name"])
86
+ give {"ok": false, "name": name, "scope": scope, "legacy": is_legacy, "error": "a tool has no valid name"}
87
+ 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})
88
+ give {"ok": true, "name": name, "scope": scope, "legacy": is_legacy, "plugin": {"name": name, "scope": scope, "dir": dir + "/" + name, "kind": kind, "legacy": is_legacy, "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}}
89
+
90
+ task scan(dir, scope, legacy)
91
+ let out be []
92
+ try
93
+ each e in list_dir(dir)
94
+ when e["is_dir"] and valid_name(e["name"])
95
+ set out to append(out, read_manifest(dir, e["name"], scope, legacy))
96
+ recover err
97
+ give out
98
+ give out
99
+
100
+ -- estado de encendido: el archivo nuevo; si no existe todavía, el viejo (solo lectura — se escribe siempre el nuevo)
101
+ task load_state()
102
+ let f be when file_exists(STATE_FILE) then STATE_FILE otherwise LEGACY_STATE_FILE
103
+ try
104
+ let doc be json_decode(read_file(f))
105
+ give when contains(doc, "enabled") then doc["enabled"] otherwise {}
106
+ recover err
107
+ give {}
108
+
109
+ -- todos los plugins descubiertos (proyecto pisa a global por nombre; la carpeta nueva pisa a la vieja), con
110
+ -- enabled, legacy y error. LAMPSON_PLUGINS_DIR = una carpeta extra (tests).
111
+ export task all()
112
+ require file(".lampson")
113
+ require file(".lampson/*")
114
+ require file.read("plugins")
115
+ require file.read("plugins/*")
116
+ require file("workspace")
117
+ require file("workspace/*")
118
+ require env("LAMPSON_*")
119
+ let by_name be {}
120
+ each r in scan(GLOBAL_DIR, "global", false)
121
+ set by_name[r["name"]] to r
122
+ each r in scan(LEGACY_PROJECT_DIR, "project", true)
123
+ set by_name[r["name"]] to r
124
+ each r in scan(PROJECT_DIR, "project", false)
125
+ set by_name[r["name"]] to r
126
+ when env("LAMPSON_PLUGINS_DIR", "") != ""
127
+ each r in scan(env("LAMPSON_PLUGINS_DIR", ""), "extra", false)
128
+ set by_name[r["name"]] to r
129
+ let st be load_state()
130
+ let out be []
131
+ each n in sort_by(keys(by_name), (x) => x)
132
+ let r be by_name[n]
133
+ let on be contains(st, n) and st[n] == true
134
+ when r["ok"]
135
+ let l be r["plugin"]
136
+ set l["enabled"] to on
137
+ set l["error"] to nothing
138
+ set out to append(out, l)
139
+ otherwise
140
+ set out to append(out, {"name": n, "scope": r["scope"], "dir": "", "kind": "?", "legacy": r["legacy"], "description": "", "entry": "", "command": "", "caps": "", "timeout": 0, "tools": [], "enabled": false, "error": r["error"]})
141
+ give out
142
+
143
+ export task enabled()
144
+ require file(".lampson")
145
+ require file(".lampson/*")
146
+ require file.read("plugins")
147
+ require file.read("plugins/*")
148
+ require file("workspace")
149
+ require file("workspace/*")
150
+ require env("LAMPSON_*")
151
+ give where(all(), (l) => l["enabled"] and l["error"] == nothing)
152
+
153
+ -- encender/apagar (el humano, o el agente tras aprobación). Encender un plugin roto es error.
154
+ export task set_enabled(name, on)
155
+ require file(".lampson")
156
+ require file(".lampson/*")
157
+ require file.read("plugins")
158
+ require file.read("plugins/*")
159
+ require file("workspace")
160
+ require file("workspace/*")
161
+ require env("LAMPSON_*")
162
+ when not valid_name(name)
163
+ raise("invalid plugin name")
164
+ let found be where(all(), (l) => l["name"] == name)
165
+ when length(found) == 0
166
+ raise("no plugin named '" + name + "' (global: " + GLOBAL_DIR + "/<name>/plugin.json · project: " + PROJECT_DIR + "/<name>/plugin.json)")
167
+ when on and found[0]["error"] != nothing
168
+ raise("plugin '" + name + "' is broken: " + text(found[0]["error"]))
169
+ let st be load_state()
170
+ set st[name] to on == true
171
+ write_file(STATE_FILE, json_encode({"enabled": st}))
172
+ let l be found[0]
173
+ let legacy_note be when l["legacy"] then " · NOTE: " + LEGACY_HINT otherwise ""
174
+ when on
175
+ give "plugin '" + 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 " + PREFIX + name + "_<tool>" + legacy_note
176
+ give "plugin '" + name + "' OFF" + legacy_note
177
+
178
+ task ceiling_of(l)
179
+ give when l["caps"] == "" then BASE_CAPS otherwise BASE_CAPS + "," + l["caps"]
180
+
181
+ -- ---------- catálogo ----------
182
+ export task tool_name(plugin, tool)
183
+ give PREFIX + plugin + "_" + tool
184
+
185
+ task split_name(full)
186
+ let segs be split(slice(full, length(PREFIX), length(full)), "_")
187
+ when length(segs) < 2
188
+ raise("bad plugin tool name '" + full + "'")
189
+ give {"plugin": segs[0], "tool": join(slice(segs, 1, length(segs)), "_")}
190
+
191
+ export task catalog(readonly_only)
192
+ require file(".lampson")
193
+ require file(".lampson/*")
194
+ require file.read("plugins")
195
+ require file.read("plugins/*")
196
+ require file("workspace")
197
+ require file("workspace/*")
198
+ require env("LAMPSON_*")
199
+ let out be []
200
+ each l in enabled()
201
+ each t in l["tools"]
202
+ when not readonly_only or t["readonly"]
203
+ set out to append(out, {"name": tool_name(l["name"], t["name"]), "description": "[plugin " + l["name"] + "] " + t["description"], "parameters": t["parameters"]})
204
+ give out
205
+
206
+ export task names(readonly_only)
207
+ require file(".lampson")
208
+ require file(".lampson/*")
209
+ require file.read("plugins")
210
+ require file.read("plugins/*")
211
+ require file("workspace")
212
+ require file("workspace/*")
213
+ require env("LAMPSON_*")
214
+ give apply(catalog(readonly_only), (s) => s["name"])
215
+
216
+ -- ---------- llamada: un proceso hijo por invocación ----------
217
+ task run_child(exe, argv, cwd, envs, t)
218
+ let p be proc_spawn(exe, argv, {"cwd": cwd, "env": envs, "stderr": "merge", "on_full": "drop_oldest"})
219
+ proc_close_stdin(p)
220
+ let deadline be now() + t
221
+ let lines be []
222
+ let code be nothing
223
+ let timed_out be false
224
+ while code == nothing and not timed_out
225
+ let left be deadline - now()
226
+ when left <= 0
227
+ set timed_out to true
228
+ otherwise
229
+ let ev be proc_recv(p, when left < 0.2 then 0.2 otherwise left)
230
+ when ev == nothing
231
+ set timed_out to now() >= deadline
232
+ otherwise when ev["type"] == "exit"
233
+ set code to ev["data"]["exit_code"]
234
+ otherwise
235
+ set lines to append(lines, ev["data"])
236
+ proc_close(p)
237
+ let out be join(lines, "\n")
238
+ when timed_out
239
+ give "ERROR: plugin timed out after " + text(t) + "s (killed). Output so far:\n" + out
240
+ when code != 0
241
+ give "ERROR: plugin exited with code " + text(code) + "\n" + out
242
+ give when trim(out) == "" then "(no output)" otherwise out
243
+
244
+ -- separa "python plugin.py --x" en exe + args (comillas simples/dobles respetadas)
245
+ export task split_command(line)
246
+ let toks be []
247
+ let cur be ""
248
+ let q be ""
249
+ let has be false
250
+ let i be 0
251
+ while i < length(line)
252
+ let ch be slice(line, i, i + 1)
253
+ set i to i + 1
254
+ when q != ""
255
+ when ch == q
256
+ set q to ""
257
+ otherwise
258
+ set cur to cur + ch
259
+ otherwise when ch == "\"" or ch == "'"
260
+ set q to ch
261
+ set has to true
262
+ otherwise when ch == " " or ch == "\t"
263
+ when cur != "" or has
264
+ set toks to append(toks, cur)
265
+ set cur to ""
266
+ set has to false
267
+ otherwise
268
+ set cur to cur + ch
269
+ when cur != "" or has
270
+ set toks to append(toks, cur)
271
+ give toks
272
+
273
+ export task call(full_name, args)
274
+ require exec
275
+ require time
276
+ require file(".lampson")
277
+ require file(".lampson/*")
278
+ require file.read("plugins")
279
+ require file.read("plugins/*")
280
+ require file("workspace")
281
+ require file("workspace/*")
282
+ require env("LAMPSON_*")
283
+ let parts be split_name(full_name)
284
+ let found be where(enabled(), (l) => l["name"] == parts["plugin"])
285
+ when length(found) == 0
286
+ give "ERROR: plugin '" + parts["plugin"] + "' is not enabled (the user turns plugins on)"
287
+ let l be found[0]
288
+ let tl be where(l["tools"], (t) => t["name"] == parts["tool"])
289
+ when length(tl) == 0
290
+ give "ERROR: plugin '" + l["name"] + "' has no tool '" + parts["tool"] + "'"
291
+ -- PLUGIN_DIR relativa a la raíz de lampson (cwd del hijo para kind=syn); PLUGIN_WORKSPACE = la ruta real del
292
+ -- proyecto (lampson.ps1 la exporta; sin ella, la junction). LAMP_* = los nombres viejos, por compat.
293
+ let tool_args be json_encode(when args == nothing then {} otherwise args)
294
+ let ws be env("LAMPSON_WORKSPACE", c.ROOT)
295
+ let envs be {"PLUGIN_TOOL": parts["tool"], "PLUGIN_ARGS": tool_args, "PLUGIN_DIR": l["dir"], "PLUGIN_WORKSPACE": ws, "LAMP_TOOL": parts["tool"], "LAMP_ARGS": tool_args, "LAMP_DIR": l["dir"], "LAMP_WORKSPACE": ws}
296
+ when l["kind"] == "syn"
297
+ -- techo del manifiesto: el código del plugin no puede pedir más de lo que el humano aprobó
298
+ give c.truncate(run_child(env("LAMPSON_SYNSEMA", "synsema"), ["run", "--cap-set", ceiling_of(l), l["dir"] + "/" + l["entry"]], ".", envs, l["timeout"]), c.MAX_OUTPUT)
299
+ let argv be split_command(l["command"])
300
+ when length(argv) == 0
301
+ give "ERROR: plugin '" + l["name"] + "' has an empty command"
302
+ -- el comando corre desde la RAÍZ DEL WORKSPACE (para que "python plugin.py" vea el proyecto), así que un
303
+ -- token que nombra un archivo de la carpeta del plugin se resuelve solo (2026-08-28: "python lamp.py"
304
+ -- salía con code 2 porque lamp.py no estaba en el cwd)
305
+ 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"]
306
+ let resolved be []
307
+ each tok in argv
308
+ when not contains(tok, "/") and not contains(tok, "\\") and file_exists(l["dir"] + "/" + tok)
309
+ set resolved to append(resolved, rel_dir + "/" + tok)
310
+ otherwise
311
+ set resolved to append(resolved, tok)
312
+ give c.truncate(run_child(resolved[0], slice(resolved, 1, length(resolved)), c.ROOT, envs, l["timeout"]), c.MAX_OUTPUT)
313
+
314
+ -- crear un plugin DEL PROYECTO (workspace/.lampson/plugins/<name>/): escribe plugin.json + el código, revalida el
315
+ -- manifiesto y, si es syn, corre `synsema check` sobre el entry. Como cordis_define en dsh: define y valida,
316
+ -- NO ejecuta ni enciende — encender sigue siendo del humano (set_enabled tras ask).
317
+ -- `files` = {"plugin.syn": "…", "helper.py": "…"}: nombres simples (sin / ni ..) dentro de la carpeta.
318
+ export task create(name, manifest, files)
319
+ require exec
320
+ require time
321
+ require file(".lampson")
322
+ require file(".lampson/*")
323
+ require file.read("plugins")
324
+ require file.read("plugins/*")
325
+ require file("workspace")
326
+ require file("workspace/*")
327
+ require env("LAMPSON_*")
328
+ when not valid_name(name)
329
+ raise("invalid plugin name '" + text(name) + "': letters, digits and - only (no _), max 32")
330
+ when manifest == nothing or not contains(manifest, "tools")
331
+ raise("manifest needs at least \"tools\" (and \"entry\" for kind=syn or \"command\" for kind=exec)")
332
+ let dir be PROJECT_DIR + "/" + name
333
+ let m be manifest
334
+ set m["name"] to name
335
+ when not contains(m, "kind")
336
+ set m["kind"] to "syn"
337
+ write_file(dir + "/plugin.json", json_encode(m))
338
+ let written be ["plugin.json"]
339
+ when files != nothing
340
+ each fname in keys(files)
341
+ when not matches(fname, "[A-Za-z0-9_.-]{1,64}") or starts_with(fname, ".")
342
+ raise("bad file name '" + fname + "' (simple names inside the plugin folder only)")
343
+ write_file(dir + "/" + fname, text(files[fname]))
344
+ set written to append(written, fname)
345
+ let found be where(all(), (l) => l["name"] == name)
346
+ when length(found) == 0
347
+ raise("plugin written but not discovered (?)")
348
+ let l be found[0]
349
+ when l["error"] != nothing
350
+ give "plugin '" + name + "' written to " + dir + " but its manifest is INVALID: " + text(l["error"]) + " — fix it with edit and call plugin(action=list) to re-check"
351
+ let note be ""
352
+ when l["kind"] == "syn"
353
+ let chk be run_child(env("LAMPSON_SYNSEMA", "synsema"), ["check", l["dir"] + "/" + l["entry"]], ".", {}, 30)
354
+ when starts_with(chk, "ERROR")
355
+ give "plugin '" + 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."
356
+ set note to " · synsema check OK · ceiling when on: " + ceiling_of(l)
357
+ when l["kind"] == "exec"
358
+ set note to " · exec (no capability ceiling: the user will see that when approving)"
359
+ give "plugin '" + 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 plugin folder named in the command is resolved automatically" otherwise "") + ". It is OFF until the user turns it on: offer to do it (plugin action=enable asks them), or point them to the plugin switch of their UI. Its tools will be " + PREFIX + name + "_<tool>."
360
+
361
+ -- eliminar un plugin DEL PROYECTO: borra su carpeta (no hay delete_file en el runtime → rm -rf por el shell
362
+ -- de bash.syn) y lo saca del estado. Los globales (plugins/) se borran a mano: son de la instalación, no del repo.
363
+ export task remove(name)
364
+ require exec
365
+ require time
366
+ require env("LAMPSON_*")
367
+ require env("OS")
368
+ require file(".lampson")
369
+ require file(".lampson/*")
370
+ require file.read("plugins")
371
+ require file.read("plugins/*")
372
+ require file("workspace")
373
+ require file("workspace/*")
374
+ when not valid_name(name)
375
+ raise("invalid plugin name")
376
+ let found be where(all(), (l) => l["name"] == name)
377
+ when length(found) == 0
378
+ raise("no plugin named '" + name + "'")
379
+ let l be found[0]
380
+ when l["scope"] != "project"
381
+ raise("'" + name + "' is a " + l["scope"] + " plugin (" + GLOBAL_DIR + "/" + name + "): delete that folder by hand")
382
+ let dir be when l["dir"] != "" then l["dir"] otherwise PROJECT_DIR + "/" + name
383
+ let is_win be env("OS", "") == "Windows_NT"
384
+ let sh be env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
385
+ let r be run(sh, ["-c", "rm -rf '" + dir + "'"], 30, {"cwd": "."})
386
+ when file_exists(dir + "/plugin.json") or file_exists(dir + "/lamp.json")
387
+ raise("could not delete " + dir + ": " + text(r["stderr"]))
388
+ let st be load_state()
389
+ let clean be {}
390
+ each k in keys(st)
391
+ when k != name
392
+ set clean[k] to st[k]
393
+ write_file(STATE_FILE, json_encode({"enabled": clean}))
394
+ give "plugin '" + name + "' deleted (" + dir + ")"
395
+
396
+ -- resumen para UI/terminal/tool
397
+ export task summary()
398
+ require file(".lampson")
399
+ require file(".lampson/*")
400
+ require file.read("plugins")
401
+ require file.read("plugins/*")
402
+ require file("workspace")
403
+ require file("workspace/*")
404
+ require env("LAMPSON_*")
405
+ let out be []
406
+ each l in all()
407
+ set out to append(out, {"name": l["name"], "scope": l["scope"], "kind": l["kind"], "legacy": l["legacy"], "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"]})
408
+ give out
package/lib/prompt.syn CHANGED
@@ -53,10 +53,11 @@ Operate like a careful senior engineer: precise, honest, and economical with wor
53
53
 
54
54
  # Tools
55
55
  - read (offset/limit), ls, find (glob), grep (regex): use them instead of bash with cat/ls/find/grep — line-numbered and cheaper. lsp (symbols/definition/references/hover): the file's structure without reading it, and exact navigation when grep is ambiguous — prefer lsp symbols plus a ranged read over reading a big file whole. edit: targeted replacement. write: create or fully replace.
56
+ - fetch (url, format, max_chars): web pages and HTTP APIs as Markdown — docs, READMEs, issues, changelogs, JSON. Use it instead of bash with curl/wget for anything you will read: it asks the site for Markdown first and strips scripts, styles and navigation, so a page costs a tenth of its HTML. A long page comes back head+tail with the full text saved to a file: read (offset/limit) or grep that file, never fetch the same URL again. Cite the URL when you use its content. Use bash+curl only for POST, custom headers or downloads.
56
57
  - bash: shell commands from the workspace root. State does not persist between calls (cd resets) — chain with &&. Check the [exit code: N] marker on every result (it is the last line) and investigate failures before moving on. Never pipe a build or test through head/tail: the output is already truncated for you (the full text is saved to a file whose path you get) and the pipe hides the real exit code. Never run a server or watcher here.
57
58
  - process: start/logs/stop long-running commands (dev servers, watchers). Their new log lines arrive automatically with every later tool result — do not sleep or poll. Stop what you started when the task is done.
58
59
  - delegate: sub-agents with a fresh context (explore, plan, review, worker); several tasks in one call run in parallel, background=true returns at once and the report arrives later as a message. Give a highly detailed, self-contained brief, say whether it should write code or only research, how to verify, and exactly what to return. Its report is a self-report — verify what matters before telling the user it is done. Its output is not visible to the user: summarise it.
59
- - memory: durable facts across sessions (user preferences, environment details, tool quirks, conventions). Check it before investigating. Write declarative facts, not instructions to yourself; do not store task progress or anything stale in a week.
60
+ - memory: durable facts across sessions (user preferences, environment details, tool quirks, conventions). Check it before investigating. Write declarative facts, not instructions to yourself; do not store task progress or anything stale in a week. The notes live outside the project: only this tool reaches them (never ls/bash, never files inside the repo).
60
61
  - skill: before starting a kind of task, scan the skill list below; if one matches or is even partially relevant, load it and follow it. Err on the side of loading.
61
62
  - todo: your task list (whole-list replacement, one in_progress at a time; see Method step 1).
62
63
  - MCP tools are named mcp_<server>_<tool>; calling one may ask the user for approval. The mcp tool lists, connects (action=add, with the user's approval) and disconnects servers; a new server's tools enter your catalog on the next turn.
@@ -70,6 +71,6 @@ Concise and direct; lead with the change or the answer, not a preamble. Prefer t
70
71
  - OS: {env_info["os"]} Shell used by bash tool: {env_info["shell"]}
71
72
  - Date: {env_info["date"]}
72
73
  - Model: {env_info["model"]} via {env_info["provider"]}
73
- - The user talks to you from the {env_info["ui"]} UI. When something needs the user's hand (turning a lamp on, adding an LSP server, connecting an MCP), tell them the way that exists in THEIR UI: web → the «lámparas» switch / the LSP and MCP sections of the sidebar; terminal → /lamps on <name>, /lsp add <preset>, /mcp add. Never tell a web user to type a slash command — or simply offer to do it yourself (the tool asks them for approval).`
74
+ - The user talks to you from the {env_info["ui"]} UI. When something needs the user's hand (turning a plugin on, adding an LSP server, connecting an MCP), tell them the way that exists in THEIR UI: web → the «plugins» switch / the LSP and MCP sections of the sidebar; terminal → /plugins on <name>, /lsp add <preset>, /mcp add. Never tell a web user to type a slash command — or simply offer to do it yourself (the tool asks them for approval).`
74
75
  let mode be when addendum == nothing then "" otherwise (when addendum == "" then "" otherwise "\n\n# Mode\n" + addendum)
75
76
  give core + mode + skills.prompt_section(skills.index()) + memo.prompt_section() + read_context_files()