lampson 0.1.2 → 0.1.4
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 +9 -0
- package/README.md +60 -2
- package/chat.syn +264 -9
- package/lampson.ps1 +17 -3
- package/lampson.sh +17 -3
- package/lib/agents.syn +1 -1
- package/lib/approvals.syn +176 -0
- package/lib/diff.syn +37 -11
- package/lib/line.syn +13 -8
- package/lib/loop.syn +25 -11
- package/lib/md.syn +76 -18
- package/lib/permission.syn +18 -0
- package/lib/prompt.syn +1 -1
- package/lib/sched_run.syn +161 -0
- package/lib/schedule.syn +660 -0
- package/lib/session.syn +38 -1
- package/lib/settings.syn +55 -0
- package/lib/tools.syn +94 -2
- package/lib/ui.syn +161 -0
- package/package.json +1 -1
- package/public/css/chat.css +56 -0
- package/public/css/layout.css +97 -0
- package/public/css/panel.css +125 -0
- package/public/css/sidebar.css +80 -0
- package/public/css/tokens.css +57 -0
- package/public/index.html +49 -1187
- package/public/js/agents.js +31 -0
- package/public/js/app.js +21 -0
- package/public/js/approvals.js +26 -0
- package/public/js/chat.js +92 -0
- package/public/js/config.js +98 -0
- package/public/js/core.js +88 -0
- package/public/js/events.js +31 -0
- package/public/js/lamps.js +112 -0
- package/public/js/lsp.js +81 -0
- package/public/js/mcp.js +74 -0
- package/public/js/memory.js +17 -0
- package/public/js/panel.js +101 -0
- package/public/js/procs.js +45 -0
- package/public/js/schedules.js +118 -0
- package/public/js/sessions.js +69 -0
- package/public/js/sidebar.js +33 -0
- package/public/js/terminal.js +49 -0
- package/public/js/theme.js +6 -0
- package/public/js/todo.js +10 -0
- package/public/js/tree.js +53 -0
- package/public/js/update.js +14 -0
- package/skills/lampson/SKILL.md +16 -0
- package/web.syn +106 -15
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
-- lib/approvals.syn — aprobaciones humanas fuera de la terminal: cola compartida + webhook + links de decisión
|
|
2
|
+
--
|
|
3
|
+
-- Un solo mecanismo para las puertas por las que un humano aprueba algo que el agente quiere hacer:
|
|
4
|
+
-- 1. la UI web (POST /api/approve desde el chat, o desde el panel «Aprobaciones» para las tareas programadas);
|
|
5
|
+
-- 2. la terminal: /approve <id> yes|no (una tarea programada corriendo en background mientras chateás);
|
|
6
|
+
-- 3. un LINK firmado que llega por el canal que sea (Telegram, mail, Slack…): GET /approve/<id>/<token>?d=yes|no
|
|
7
|
+
-- — de un solo uso; el token es un secreto por aprobación (32 bytes CSPRNG), igual que el `approve` nativo
|
|
8
|
+
-- de Synsema bajo serve (doc 62-human), solo que acá también sirve para las tareas del cron y para la UI;
|
|
9
|
+
-- 4. la consola del proceso (el link se imprime ahí también).
|
|
10
|
+
-- Además, si LAMPSON_WEBHOOK_URL está configurado (o config.json {"webhook_url"}), cada aprobación pendiente dispara
|
|
11
|
+
-- un POST JSON con {id, message, why, expires_at, respond_link_yes, respond_link_no} firmado con HMAC-SHA256
|
|
12
|
+
-- (X-Lampson-Signature: sha256=<hex>, secreto LAMPSON_WEBHOOK_SECRET) — el mismo patrón que Stripe/GitHub, así
|
|
13
|
+
-- cualquier receptor (n8n, un bot, otro .syn) reenvía los links. Los links necesitan LAMPSON_PUBLIC_URL (la URL
|
|
14
|
+
-- por la que se llega a este lampson desde afuera: un túnel, el VPS, un edge Synsema con TLS).
|
|
15
|
+
-- Fire-and-forget (10 s, un intento): un canal caído nunca bloquea; la UI y la consola quedan como respaldo.
|
|
16
|
+
--
|
|
17
|
+
-- Estado: BLACKBOARD (share/observe), que existe bajo `serve` y bajo `run` y lo ven handlers, agentes y ticks
|
|
18
|
+
-- (`state_*` solo existe bajo serve — verificado 2026-08-29). Claves: "approval:meta:<id>" (visible, sin token),
|
|
19
|
+
-- "approval:token:<id>", "approval:answer:<id>" (yes|no) y el índice "approval:index" (lista de ids).
|
|
20
|
+
--
|
|
21
|
+
-- Garantía: NUNCA se auto-aprueba. Sin respuesta al vencer el plazo → denegado (fail-closed), como Synsema.
|
|
22
|
+
|
|
23
|
+
use "./settings.syn" as settings
|
|
24
|
+
|
|
25
|
+
task read_cfg(key, envname)
|
|
26
|
+
let v be env(envname, "")
|
|
27
|
+
when v != ""
|
|
28
|
+
give v
|
|
29
|
+
let doc be settings.load()
|
|
30
|
+
when contains(doc, key)
|
|
31
|
+
give text(doc[key])
|
|
32
|
+
give ""
|
|
33
|
+
|
|
34
|
+
export task webhook_url()
|
|
35
|
+
require env("LAMPSON_*")
|
|
36
|
+
require file.read(".lampson")
|
|
37
|
+
require file.read(".lampson/*")
|
|
38
|
+
give read_cfg("webhook_url", "LAMPSON_WEBHOOK_URL")
|
|
39
|
+
|
|
40
|
+
export task public_url()
|
|
41
|
+
require env("LAMPSON_*")
|
|
42
|
+
require file.read(".lampson")
|
|
43
|
+
require file.read(".lampson/*")
|
|
44
|
+
let u be read_cfg("public_url", "LAMPSON_PUBLIC_URL")
|
|
45
|
+
while length(u) > 0 and slice(u, length(u) - 1, length(u)) == "/"
|
|
46
|
+
set u to slice(u, 0, length(u) - 1)
|
|
47
|
+
give u
|
|
48
|
+
|
|
49
|
+
task secret_text()
|
|
50
|
+
give read_cfg("webhook_secret", "LAMPSON_WEBHOOK_SECRET")
|
|
51
|
+
|
|
52
|
+
task index()
|
|
53
|
+
observe "approval:index" as ix
|
|
54
|
+
give when ix == nothing then [] otherwise ix
|
|
55
|
+
|
|
56
|
+
task meta_of(id)
|
|
57
|
+
observe "approval:meta:" + id as m
|
|
58
|
+
give m
|
|
59
|
+
|
|
60
|
+
task set_meta(id, m)
|
|
61
|
+
share m as "approval:meta:" + id
|
|
62
|
+
|
|
63
|
+
-- abre una aprobación: id único, token de un solo uso, metadatos visibles (sin token) para la UI.
|
|
64
|
+
-- source = "chat" | "schedule:<id>"; message = lo que se quiere hacer; why = por qué pide permiso.
|
|
65
|
+
export task open(source, message, why, timeout_s)
|
|
66
|
+
require time
|
|
67
|
+
require random
|
|
68
|
+
require net
|
|
69
|
+
require env("LAMPSON_*")
|
|
70
|
+
require file.read(".lampson")
|
|
71
|
+
require file.read(".lampson/*")
|
|
72
|
+
let id be "a" + slice(text(floor(now() * 1000)), 4, 13) + "-" + slice(token(16), 0, 6)
|
|
73
|
+
let tok be token(32)
|
|
74
|
+
let meta be {"id": id, "source": source, "message": message, "why": why, "created": now(), "expires_at": floor(now() + timeout_s), "status": "pending"}
|
|
75
|
+
set_meta(id, meta)
|
|
76
|
+
share tok as "approval:token:" + id
|
|
77
|
+
share nothing as "approval:answer:" + id
|
|
78
|
+
share append(index(), id) as "approval:index"
|
|
79
|
+
bus_publish("approval.request", meta)
|
|
80
|
+
let links be links_for(id, tok)
|
|
81
|
+
print("[lampson] aprobación pendiente " + id + " — " + message + " (vence en " + text(floor(timeout_s)) + " s)" + (when links["yes"] != "" then "\n sí: " + links["yes"] + "\n no: " + links["no"] otherwise "\n respondé desde la UI web (Aprobaciones), con /approve " + id + " yes|no en la terminal, o configurá la URL pública para recibir links"))
|
|
82
|
+
notify(meta, links)
|
|
83
|
+
give id
|
|
84
|
+
|
|
85
|
+
task links_for(id, tok)
|
|
86
|
+
let base be public_url()
|
|
87
|
+
when base == ""
|
|
88
|
+
give {"yes": "", "no": "", "path": "/approve/" + id + "/" + tok}
|
|
89
|
+
give {"yes": base + "/approve/" + id + "/" + tok + "?d=yes", "no": base + "/approve/" + id + "/" + tok + "?d=no", "path": "/approve/" + id + "/" + tok}
|
|
90
|
+
|
|
91
|
+
-- webhook firmado (fire-and-forget)
|
|
92
|
+
task notify(meta, links)
|
|
93
|
+
let url be webhook_url()
|
|
94
|
+
when url == ""
|
|
95
|
+
give false
|
|
96
|
+
let payload be {"id": meta["id"], "type": "approve", "source": meta["source"], "message": meta["message"], "why": meta["why"], "expires_at": meta["expires_at"], "respond_path": links["path"], "respond_link_yes": links["yes"], "respond_link_no": links["no"]}
|
|
97
|
+
let body be json_encode(payload)
|
|
98
|
+
let headers be {"Content-Type": "application/json", "User-Agent": "lampson"}
|
|
99
|
+
let sec be secret_text()
|
|
100
|
+
when sec != ""
|
|
101
|
+
set headers["X-Lampson-Signature"] to "sha256=" + hmac_sha256(body, sec)
|
|
102
|
+
try
|
|
103
|
+
let r be http_post(url, body, headers)
|
|
104
|
+
when not r["ok"]
|
|
105
|
+
print("[lampson] webhook " + url + " respondió " + text(r["status"]))
|
|
106
|
+
give r["ok"]
|
|
107
|
+
recover err
|
|
108
|
+
print("[lampson] webhook falló: " + text(err))
|
|
109
|
+
give false
|
|
110
|
+
|
|
111
|
+
-- espera (polling) hasta que alguien responda o venza el plazo. Devuelve true/false; sin respuesta = false.
|
|
112
|
+
export task wait(id, timeout_s)
|
|
113
|
+
require time
|
|
114
|
+
let deadline be now() + timeout_s
|
|
115
|
+
let answer be nothing
|
|
116
|
+
while answer == nothing and now() < deadline
|
|
117
|
+
observe "approval:answer:" + id as a
|
|
118
|
+
set answer to a
|
|
119
|
+
when answer == nothing
|
|
120
|
+
sleep(0.5)
|
|
121
|
+
let meta be meta_of(id)
|
|
122
|
+
let ok be answer == "yes"
|
|
123
|
+
when meta != nothing
|
|
124
|
+
set meta["status"] to when answer == nothing then "expired" otherwise (when ok then "approved" otherwise "denied")
|
|
125
|
+
set meta["answered"] to now()
|
|
126
|
+
set_meta(id, meta)
|
|
127
|
+
bus_publish("approval.result", meta)
|
|
128
|
+
share nothing as "approval:token:" + id
|
|
129
|
+
give ok
|
|
130
|
+
|
|
131
|
+
-- responder desde la UI (loopback, ya autenticado) o desde la terminal: sin token
|
|
132
|
+
export task answer(id, decision)
|
|
133
|
+
let meta be meta_of(id)
|
|
134
|
+
when meta == nothing
|
|
135
|
+
give false
|
|
136
|
+
when meta["status"] != "pending"
|
|
137
|
+
give false
|
|
138
|
+
let v be when decision then "yes" otherwise "no"
|
|
139
|
+
share v as "approval:answer:" + id
|
|
140
|
+
give true
|
|
141
|
+
|
|
142
|
+
-- responder por link: el token debe coincidir (comparación en tiempo constante) y ser de un solo uso
|
|
143
|
+
export task answer_with_token(id, tok, decision)
|
|
144
|
+
observe "approval:token:" + id as want
|
|
145
|
+
when want == nothing
|
|
146
|
+
give {"ok": false, "why": "expired"}
|
|
147
|
+
when not verify_hmac(id, hmac_sha256(id, tok), want)
|
|
148
|
+
give {"ok": false, "why": "bad token"}
|
|
149
|
+
share nothing as "approval:token:" + id
|
|
150
|
+
let v be when decision then "yes" otherwise "no"
|
|
151
|
+
share v as "approval:answer:" + id
|
|
152
|
+
give {"ok": true, "why": ""}
|
|
153
|
+
|
|
154
|
+
-- pendientes y recientes para la UI (nunca incluye tokens); las resueltas hace más de 1 h se olvidan
|
|
155
|
+
export task list()
|
|
156
|
+
require time
|
|
157
|
+
let out be []
|
|
158
|
+
let keep be []
|
|
159
|
+
each id in index()
|
|
160
|
+
let m be meta_of(id)
|
|
161
|
+
when m != nothing
|
|
162
|
+
when m["status"] != "pending" and contains(m, "answered") and now() - m["answered"] > 3600
|
|
163
|
+
share nothing as "approval:meta:" + id
|
|
164
|
+
otherwise
|
|
165
|
+
set out to append(out, m)
|
|
166
|
+
set keep to append(keep, id)
|
|
167
|
+
when length(keep) != length(index())
|
|
168
|
+
share keep as "approval:index"
|
|
169
|
+
give sort_by(out, (m) => 0 - m["created"])
|
|
170
|
+
|
|
171
|
+
export task pending()
|
|
172
|
+
give where(list(), (m) => m["status"] == "pending")
|
|
173
|
+
|
|
174
|
+
-- página mínima que ve quien abre el link
|
|
175
|
+
export task html_page(title, body)
|
|
176
|
+
give "<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>" + title + "</title><body style='font:16px/1.5 system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#f6f4ef;color:#1d1c1a'><div style='max-width:520px;padding:32px;border:1px solid #d9d4c7;border-radius:6px;background:#fffdf8'><p style='margin:0 0 6px;font:11px/1 ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase;color:#a33'>lampson</p><h1 style='font-size:22px;margin:0 0 12px'>" + title + "</h1><p style='margin:0'>" + body + "</p></div></body>"
|
package/lib/diff.syn
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
-- lib/diff.syn — diff de líneas para mostrar en la terminal qué cambió edit/write
|
|
2
2
|
-- diff(old, new, ctx, color) → {"added": n, "removed": m, "lines": [texto ya formateado…]}
|
|
3
3
|
-- Algoritmo: se recorta el prefijo y sufijo comunes (una edición es local) y se hace LCS solo sobre el
|
|
4
|
-
-- medio. Si el medio
|
|
4
|
+
-- medio. Si el medio supera LIMIT² celdas (80×80 líneas cambiadas ≈ un rewrite) se muestra como bloque
|
|
5
|
+
-- quitado + bloque agregado: exacto y en milisegundos (el LCS interpretado a 300×300 tardaba 2 minutos).
|
|
6
|
+
|
|
7
|
+
use "./ui.syn" as ui
|
|
5
8
|
|
|
6
9
|
let ESC be decode(bytes("1b", "hex"))
|
|
7
|
-
let LIMIT be
|
|
10
|
+
let LIMIT be 80
|
|
11
|
+
|
|
12
|
+
-- línea de diff con fondo (verde/rojo) rellenada hasta el ancho útil, cortada si se pasa
|
|
13
|
+
task row(color, kind, num, body, width)
|
|
14
|
+
let n be sgr(color, "2", lpad(num, width) + " │ ")
|
|
15
|
+
let w be ui.cols() - 8 - width - 3
|
|
16
|
+
when w < 30
|
|
17
|
+
set w to 30
|
|
18
|
+
let txt be ui.cut(kind + " " + body, w)
|
|
19
|
+
let pad be rep(" ", w - ui.width(txt))
|
|
20
|
+
when kind == "+"
|
|
21
|
+
give n + ui.bg_add(color, sgr(color, "32", txt + pad))
|
|
22
|
+
when kind == "-"
|
|
23
|
+
give n + ui.bg_del(color, sgr(color, "31", txt + pad))
|
|
24
|
+
give n + sgr(color, "2", ui.cut(" " + body, w))
|
|
25
|
+
|
|
26
|
+
task rep(ch, n)
|
|
27
|
+
let out be ""
|
|
28
|
+
while length(out) < n
|
|
29
|
+
set out to out + ch
|
|
30
|
+
give out
|
|
8
31
|
|
|
9
32
|
task sgr(color, code, s)
|
|
10
33
|
when not color
|
|
@@ -22,10 +45,13 @@ task lcs_ops(a, b)
|
|
|
22
45
|
let n be length(a)
|
|
23
46
|
let m be length(b)
|
|
24
47
|
-- tabla (n+1)×(m+1) aplanada, fila por fila
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
-- MIGA: append copia la lista entera (O(n) cada vez) → construirla con append era O(n²·m²).
|
|
49
|
+
-- range() la crea de una y set por índice es O(1).
|
|
50
|
+
let table be range(0, (n + 1) * (m + 1))
|
|
51
|
+
let z be 0
|
|
52
|
+
while z < (n + 1) * (m + 1)
|
|
53
|
+
set table[z] to 0
|
|
54
|
+
set z to z + 1
|
|
29
55
|
let i be n - 1
|
|
30
56
|
while i >= 0
|
|
31
57
|
let j be m - 1
|
|
@@ -98,7 +124,7 @@ export task diff(old, new, ctx, color)
|
|
|
98
124
|
set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
|
|
99
125
|
let k be start
|
|
100
126
|
while k < pre
|
|
101
|
-
set lines to append(lines,
|
|
127
|
+
set lines to append(lines, row(color, " ", text(k + 1), a[k], width))
|
|
102
128
|
set k to k + 1
|
|
103
129
|
-- medio: contexto interno acotado (ctx a cada lado de un cambio)
|
|
104
130
|
let last_change be -1
|
|
@@ -118,16 +144,16 @@ export task diff(old, new, ctx, color)
|
|
|
118
144
|
set w to w + 1
|
|
119
145
|
when o["op"] == " "
|
|
120
146
|
when near
|
|
121
|
-
set lines to append(lines,
|
|
147
|
+
set lines to append(lines, row(color, " ", text(pre + o["b"] + 1), mid_b[o["b"]], width))
|
|
122
148
|
set skipping to false
|
|
123
149
|
otherwise when not skipping
|
|
124
150
|
set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
|
|
125
151
|
set skipping to true
|
|
126
152
|
otherwise when o["op"] == "-"
|
|
127
|
-
set lines to append(lines,
|
|
153
|
+
set lines to append(lines, row(color, "-", text(pre + o["a"] + 1), mid_a[o["a"]], width))
|
|
128
154
|
set skipping to false
|
|
129
155
|
otherwise
|
|
130
|
-
set lines to append(lines,
|
|
156
|
+
set lines to append(lines, row(color, "+", text(pre + o["b"] + 1), mid_b[o["b"]], width))
|
|
131
157
|
set skipping to false
|
|
132
158
|
set idx to idx + 1
|
|
133
159
|
-- contexto posterior (del sufijo común)
|
|
@@ -135,7 +161,7 @@ export task diff(old, new, ctx, color)
|
|
|
135
161
|
let upto be when after_start + ctx < length(b) then after_start + ctx otherwise length(b)
|
|
136
162
|
set k to after_start
|
|
137
163
|
while k < upto
|
|
138
|
-
set lines to append(lines,
|
|
164
|
+
set lines to append(lines, row(color, " ", text(k + 1), b[k], width))
|
|
139
165
|
set k to k + 1
|
|
140
166
|
when upto < length(b)
|
|
141
167
|
set lines to append(lines, sgr(color, "2", lpad("", width) + " ⋯"))
|
package/lib/line.syn
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
-- devuelva ctx["complete"](cmd, prefijo) (archivos, servers, flags…). Máx MENU_ROWS filas.
|
|
9
9
|
-- MIGA: dibujar SIEMPRE con term_write (print queda buffereado). El runtime restaura la terminal al cerrar.
|
|
10
10
|
|
|
11
|
+
use "./ui.syn" as ui
|
|
12
|
+
|
|
11
13
|
let ESC be decode(bytes("1b", "hex"))
|
|
12
14
|
let MENU_ROWS be 8
|
|
13
15
|
export let INTERRUPT be ESC + "interrupt"
|
|
@@ -18,7 +20,7 @@ task sgr(color, code, s)
|
|
|
18
20
|
give ESC + "[" + code + "m" + s + ESC + "[0m"
|
|
19
21
|
|
|
20
22
|
task vis(s)
|
|
21
|
-
give
|
|
23
|
+
give ui.width(s)
|
|
22
24
|
|
|
23
25
|
task rep(ch, n)
|
|
24
26
|
let out be ""
|
|
@@ -102,13 +104,14 @@ task draw(h, st, color, ctx)
|
|
|
102
104
|
set i to 0
|
|
103
105
|
each l in lines
|
|
104
106
|
let pre be when i == 0 then st["prompt"] otherwise cont
|
|
105
|
-
set out to out + (when i > 0 then "\r\n" otherwise "") + pre + l
|
|
106
|
-
let r be rows_of(vis(pre) +
|
|
107
|
+
set out to out + (when i > 0 then "\r\n" otherwise "") + pre + (when st["final"] and l != "" then sgr(color, "7", " " + l + " ") otherwise l)
|
|
108
|
+
let r be rows_of(vis(pre) + ui.width(l), cols)
|
|
107
109
|
when i < cl
|
|
108
110
|
set rows_before_cursor to rows_before_cursor + r
|
|
109
111
|
otherwise when i == cl
|
|
110
|
-
|
|
111
|
-
set
|
|
112
|
+
let ccw be ui.width(slice(l, 0, cc))
|
|
113
|
+
set rows_before_cursor to rows_before_cursor + floor((vis(pre) + ccw) / cols)
|
|
114
|
+
set cursor_col to (vis(pre) + ccw) % cols
|
|
112
115
|
set total to total + r
|
|
113
116
|
set i to i + 1
|
|
114
117
|
-- menú
|
|
@@ -182,7 +185,8 @@ export task read(prompt, color, ctx)
|
|
|
182
185
|
let h be term_open({"ctrl_c": "exit"})
|
|
183
186
|
when h == nothing
|
|
184
187
|
give ctx["fallback"](prompt)
|
|
185
|
-
|
|
188
|
+
ui.set_cols(term_size(h)["cols"])
|
|
189
|
+
let st be {"buf": "", "cur": 0, "prompt": prompt, "cursor_row": 0, "menu": false, "menu_off": false, "kind": "", "items": [], "sel": 0, "nav": false, "final": false}
|
|
186
190
|
let hist be ctx["history"]
|
|
187
191
|
let hi be length(hist)
|
|
188
192
|
let draft be ""
|
|
@@ -207,7 +211,7 @@ export task read(prompt, color, ctx)
|
|
|
207
211
|
set st to insert(st, replace_text(ev["text"], "\r", ""))
|
|
208
212
|
set st to refresh_menu(st, ctx)
|
|
209
213
|
otherwise when ev["type"] == "resize"
|
|
210
|
-
|
|
214
|
+
ui.set_cols(ev["cols"])
|
|
211
215
|
otherwise when ev["type"] == "key"
|
|
212
216
|
let k be ev["key"]
|
|
213
217
|
let changed be true
|
|
@@ -300,13 +304,14 @@ export task read(prompt, color, ctx)
|
|
|
300
304
|
set st to draw(h, st, color, ctx)
|
|
301
305
|
-- dejar el prompt limpio (sin menú) en el scrollback y bajar a una línea nueva
|
|
302
306
|
set st["menu"] to false
|
|
307
|
+
set st["final"] to result != nothing and result != INTERRUPT and trim(st["buf"]) != ""
|
|
303
308
|
set st to draw(h, st, color, ctx)
|
|
304
309
|
let lines be split(st["buf"], "\n")
|
|
305
310
|
let size be term_size(h)
|
|
306
311
|
let tail be 0
|
|
307
312
|
let i be 0
|
|
308
313
|
each l in lines
|
|
309
|
-
let pre be when i == 0 then vis(st["prompt"]) otherwise 4
|
|
314
|
+
let pre be (when i == 0 then vis(st["prompt"]) otherwise 4) + (when st["final"] then 2 otherwise 0)
|
|
310
315
|
when i > 0 or true
|
|
311
316
|
set tail to tail + rows_of(pre + length(l), size["cols"])
|
|
312
317
|
set i to i + 1
|
package/lib/loop.syn
CHANGED
|
@@ -55,7 +55,7 @@ export task explore_cap()
|
|
|
55
55
|
let e be env("LAMPSON_EXPLORE_CAP", "")
|
|
56
56
|
when e != ""
|
|
57
57
|
give floor(number(e))
|
|
58
|
-
give
|
|
58
|
+
give 24
|
|
59
59
|
|
|
60
60
|
-- {streak, note, refuse}: nuevo streak tras esta llamada y qué hacer con ella
|
|
61
61
|
export task explore_verdict(streak, name, cap)
|
|
@@ -100,6 +100,9 @@ export task spill(name, id, out)
|
|
|
100
100
|
require file(".lampson/*")
|
|
101
101
|
when name == "read" or length(out) <= SPILL_CAP
|
|
102
102
|
give out
|
|
103
|
+
-- el informe de los subagentes es el entregable: no se manda a disco (hasta 60k)
|
|
104
|
+
when name == "delegate" and length(out) <= SPILL_CAP * 6
|
|
105
|
+
give out
|
|
103
106
|
let path be SPILL_DIR + "/" + id + ".txt"
|
|
104
107
|
write_file(path, out)
|
|
105
108
|
let half be floor(SPILL_CAP / 2)
|
|
@@ -276,17 +279,21 @@ task execute(tc, opts, on_event)
|
|
|
276
279
|
when not approved
|
|
277
280
|
emit(on_event, "tool_denied", {"call": tc, "reason": "user declined (" + verdict["reason"] + ")"}, tag)
|
|
278
281
|
give "DENIED by the user (" + verdict["reason"] + "). Do not retry this exact action; ask the user or propose an alternative."
|
|
282
|
+
emit(on_event, "busy", {"label": permission.describe_call(name, args), "kind": "tool", "name": name}, tag)
|
|
283
|
+
let out be ""
|
|
279
284
|
try
|
|
280
285
|
-- tools MCP: no son tasks Synsema (args libres); el registry las marca con "mcp"
|
|
281
286
|
when registry[name] == "mcp"
|
|
282
|
-
|
|
287
|
+
set out to text(mcp.call(name, args))
|
|
283
288
|
-- tools de lámparas: un proceso hijo por llamada (lib/lamps.syn)
|
|
284
|
-
when registry[name] == "lamp"
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
289
|
+
otherwise when registry[name] == "lamp"
|
|
290
|
+
set out to text(lamps.call(name, args))
|
|
291
|
+
otherwise
|
|
292
|
+
set out to text(call_tool(registry[name], args))
|
|
288
293
|
recover err
|
|
289
|
-
|
|
294
|
+
set out to "ERROR: " + err
|
|
295
|
+
emit(on_event, "idle", nothing, tag)
|
|
296
|
+
give out
|
|
290
297
|
|
|
291
298
|
export task run_turn(cfg, messages, opts, on_event)
|
|
292
299
|
require net
|
|
@@ -353,7 +360,9 @@ export task run_turn(cfg, messages, opts, on_event)
|
|
|
353
360
|
when last_step
|
|
354
361
|
let why be when over_budget then "Token budget for this turn is exhausted (" + text(spent) + " of " + text(floor(budget)) + " tokens)" otherwise "Step limit reached for this turn (" + text(max_steps) + " tool calls)"
|
|
355
362
|
set msgs to append(msgs, {"role": "user", "content": "[harness] " + why + ". Do NOT call tools now. Reply with a short status: what you completed, what is verified, and exactly what remains to do next. The user can say 'continue' to resume."})
|
|
363
|
+
emit(on_event, "busy", {"label": "pensando", "kind": "llm"}, tag)
|
|
356
364
|
let r be provider.chat_retry(cfg, msgs, cat, 3)
|
|
365
|
+
emit(on_event, "idle", nothing, tag)
|
|
357
366
|
when r["error"] != nothing
|
|
358
367
|
emit(on_event, "error", r["error"], tag)
|
|
359
368
|
set stopped to "error: " + r["error"]
|
|
@@ -406,16 +415,21 @@ export task run_turn(cfg, messages, opts, on_event)
|
|
|
406
415
|
when ex["note"] != ""
|
|
407
416
|
set out to out + ex["note"]
|
|
408
417
|
emit(on_event, "inbox", "tope de exploración a la mitad (" + text(explore_streak) + "/" + text(cap) + " lecturas seguidas sin actuar): aviso al modelo", tag)
|
|
409
|
-
|
|
418
|
+
-- solo cuentan los errores REALES de la tool; los rechazos del harness (tope de lecturas,
|
|
419
|
+
-- repeticiones) ya castigan con el rechazo y no deben además cortar el turno
|
|
420
|
+
when (starts_with(out, "ERROR") or starts_with(out, "DENIED")) and not starts_with(out, "ERROR: exploration cap") and not starts_with(out, "ERROR: this turn already made") and not starts_with(out, "ERROR: you already called")
|
|
410
421
|
set errors to errors + 1
|
|
411
422
|
emit(on_event, "tool_result", {"call": tc, "output": out}, tag)
|
|
412
423
|
set msgs to append(msgs, tool_result_msg(tc, out))
|
|
413
424
|
when errors >= MAX_ERRORS_PER_TURN
|
|
414
425
|
set msgs to append(msgs, {"role": "user", "content": "[harness] Too many tool errors this turn. Stop calling tools and report what happened."})
|
|
426
|
+
emit(on_event, "busy", {"label": "pensando", "kind": "llm"}, tag)
|
|
415
427
|
let fin be provider.chat_retry(cfg, msgs, [], 2)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
428
|
+
emit(on_event, "idle", nothing, tag)
|
|
429
|
+
let fin_text be strip_raw_tool_calls(fin["text"])
|
|
430
|
+
set msgs to append(msgs, {"role": "assistant", "content": fin_text, "tool_calls": []})
|
|
431
|
+
emit(on_event, "assistant", fin_text, tag)
|
|
432
|
+
give {"messages": msgs, "text": fin_text, "steps": steps, "usage": usage, "stopped": "too_many_errors"}
|
|
419
433
|
set stopped to when spent > budget then "budget" otherwise "max_steps"
|
|
420
434
|
give {"messages": msgs, "text": final_text, "steps": steps, "usage": usage, "stopped": stopped}
|
|
421
435
|
|
package/lib/md.syn
CHANGED
|
@@ -5,8 +5,26 @@
|
|
|
5
5
|
-- (backrefs \1). En strings "..." la barra va simple ("\*", "\d"): "\\d" NO es \d.
|
|
6
6
|
-- Los bloques ``` se copian tal cual (sin parseo inline). La web ya renderiza markdown por su cuenta.
|
|
7
7
|
|
|
8
|
+
use "./ui.syn" as ui
|
|
9
|
+
|
|
8
10
|
let ESC be decode(bytes("1b", "hex"))
|
|
9
|
-
|
|
11
|
+
-- ancho útil: columnas de la terminal menos la sangría (mín. 40, máx. 100 para que la prosa se lea)
|
|
12
|
+
task width_now()
|
|
13
|
+
let c be ui.cols() - 4
|
|
14
|
+
when c < 40
|
|
15
|
+
give 40
|
|
16
|
+
when c > 100
|
|
17
|
+
give 100
|
|
18
|
+
give c
|
|
19
|
+
|
|
20
|
+
-- rutas y símbolos de código dentro de la prosa → azul (lib/x.syn, src/a/b.ts, describe_call(), foo.bar)
|
|
21
|
+
task paths(s, color)
|
|
22
|
+
when not color
|
|
23
|
+
give s
|
|
24
|
+
let out be replace_re(s, "(^|[\s(])([\w.-]*/[\w./-]{2,})", "\1" + ESC + "[34m\2" + ESC + "[0m")
|
|
25
|
+
set out to replace_re(out, "(^|[\s(])(\w+\.(syn|js|ts|tsx|jsx|json|md|py|rs|go|ps1|sh|html|css|toml|yml|yaml|env))(\b)", "\1" + ESC + "[34m\2" + ESC + "[0m")
|
|
26
|
+
set out to replace_re(out, "(^|[\s(])(\w+\(\))", "\1" + ESC + "[34m\2" + ESC + "[0m")
|
|
27
|
+
give out
|
|
10
28
|
|
|
11
29
|
task sgr(color, code, s)
|
|
12
30
|
when not color or s == ""
|
|
@@ -41,16 +59,16 @@ export task inline(s, color)
|
|
|
41
59
|
-- `código` primero: lo de adentro no se toca
|
|
42
60
|
let parts be split(s, "`")
|
|
43
61
|
when length(parts) < 3
|
|
44
|
-
give emphasis(s, color)
|
|
62
|
+
give paths(emphasis(s, color), color)
|
|
45
63
|
let out be ""
|
|
46
64
|
let idx be 0
|
|
47
65
|
each p in parts
|
|
48
66
|
when idx == length(parts) - 1 and idx % 2 == 1
|
|
49
|
-
set out to out + "`" + emphasis(p, color)
|
|
67
|
+
set out to out + "`" + paths(emphasis(p, color), color)
|
|
50
68
|
otherwise when idx % 2 == 1
|
|
51
|
-
set out to out + sgr(color, "
|
|
69
|
+
set out to out + sgr(color, "36", p)
|
|
52
70
|
otherwise
|
|
53
|
-
set out to out + emphasis(p, color)
|
|
71
|
+
set out to out + paths(emphasis(p, color), color)
|
|
54
72
|
set idx to idx + 1
|
|
55
73
|
give out
|
|
56
74
|
|
|
@@ -79,19 +97,51 @@ task table_lines(rows, pre, color)
|
|
|
79
97
|
each c in r
|
|
80
98
|
when i >= length(widths)
|
|
81
99
|
set widths to append(widths, 0)
|
|
82
|
-
when
|
|
83
|
-
set widths[i] to
|
|
100
|
+
when ui.width(inline(c, false)) > widths[i]
|
|
101
|
+
set widths[i] to ui.width(inline(c, false))
|
|
84
102
|
set i to i + 1
|
|
103
|
+
-- si no entra en la terminal, achicar la columna más ancha hasta que entre (mínimo 10) y envolver
|
|
104
|
+
let avail be width_now() - 3 * (length(widths) - 1)
|
|
105
|
+
let total be 0
|
|
106
|
+
each w in widths
|
|
107
|
+
set total to total + w
|
|
108
|
+
while total > avail
|
|
109
|
+
let widest be 0
|
|
110
|
+
let wi be 0
|
|
111
|
+
each w in widths
|
|
112
|
+
when w > widths[widest]
|
|
113
|
+
set widest to wi
|
|
114
|
+
set wi to wi + 1
|
|
115
|
+
when widths[widest] <= 10
|
|
116
|
+
set total to avail
|
|
117
|
+
otherwise
|
|
118
|
+
set widths[widest] to widths[widest] - 1
|
|
119
|
+
set total to total - 1
|
|
85
120
|
let out be []
|
|
86
121
|
let ri be 0
|
|
87
122
|
each r in rows
|
|
88
|
-
|
|
123
|
+
-- cada celda → lista de líneas envueltas a su ancho
|
|
124
|
+
let wrapped be []
|
|
125
|
+
let height be 1
|
|
89
126
|
let i be 0
|
|
90
127
|
each c in r
|
|
91
|
-
let
|
|
92
|
-
set
|
|
128
|
+
let ls be ui.wrap(c, widths[i], "")
|
|
129
|
+
set wrapped to append(wrapped, ls)
|
|
130
|
+
when length(ls) > height
|
|
131
|
+
set height to length(ls)
|
|
93
132
|
set i to i + 1
|
|
94
|
-
|
|
133
|
+
let li be 0
|
|
134
|
+
while li < height
|
|
135
|
+
let cells be []
|
|
136
|
+
set i to 0
|
|
137
|
+
each ls in wrapped
|
|
138
|
+
let piece be when li < length(ls) then ls[li] otherwise ""
|
|
139
|
+
let vis be ui.width(inline(piece, false))
|
|
140
|
+
let fill be rep(" ", (when widths[i] > vis then widths[i] - vis otherwise 0))
|
|
141
|
+
set cells to append(cells, (when ri == 0 then sgr(color, "1", inline(piece, color)) otherwise inline(piece, color)) + fill)
|
|
142
|
+
set i to i + 1
|
|
143
|
+
set out to append(out, pre + join(cells, sgr(color, "2", " │ ")))
|
|
144
|
+
set li to li + 1
|
|
95
145
|
when ri == 0
|
|
96
146
|
let segs be []
|
|
97
147
|
each w in widths
|
|
@@ -102,7 +152,7 @@ task table_lines(rows, pre, color)
|
|
|
102
152
|
|
|
103
153
|
task fence_top(lang, color)
|
|
104
154
|
let label be when lang != "" then " " + lang + " " otherwise ""
|
|
105
|
-
give sgr(color, "2", "┌──" + label + rep("─",
|
|
155
|
+
give sgr(color, "2", "┌──" + label + rep("─", width_now() - 3 - length(label)))
|
|
106
156
|
|
|
107
157
|
export task render(md, color)
|
|
108
158
|
let pre be " "
|
|
@@ -122,9 +172,9 @@ export task render(md, color)
|
|
|
122
172
|
when in_code
|
|
123
173
|
when starts_with(t, fence)
|
|
124
174
|
set in_code to false
|
|
125
|
-
set out to append(out, pre + sgr(color, "2", "└" + rep("─",
|
|
175
|
+
set out to append(out, pre + sgr(color, "2", "└" + rep("─", width_now() - 1)))
|
|
126
176
|
otherwise
|
|
127
|
-
set out to append(out, pre + sgr(color, "2", "│ ") + sgr(color, "36", line))
|
|
177
|
+
set out to append(out, pre + sgr(color, "2", "│ ") + ui.cut(sgr(color, "36", line), width_now() - 2))
|
|
128
178
|
otherwise when starts_with(t, "```") or starts_with(t, "~~~")
|
|
129
179
|
set in_code to true
|
|
130
180
|
set fence to slice(t, 0, 3)
|
|
@@ -143,7 +193,7 @@ export task render(md, color)
|
|
|
143
193
|
otherwise
|
|
144
194
|
set out to append(out, pre + sgr(color, "1", title))
|
|
145
195
|
otherwise when matches(t, "(-{3,}|\*{3,}|_{3,})")
|
|
146
|
-
set out to append(out, pre + sgr(color, "2", rep("─",
|
|
196
|
+
set out to append(out, pre + sgr(color, "2", rep("─", width_now())))
|
|
147
197
|
otherwise when starts_with(t, ">")
|
|
148
198
|
set out to append(out, pre + sgr(color, "2", "▎ ") + sgr(color, "3", inline(trim(slice(t, 1, length(t))), color)))
|
|
149
199
|
otherwise when matches(t, "\|[\s:|-]+\|")
|
|
@@ -160,12 +210,20 @@ export task render(md, color)
|
|
|
160
210
|
otherwise when starts_with(rest, "[x] ") or starts_with(rest, "[X] ")
|
|
161
211
|
set mark to sgr(color, "32", "☑")
|
|
162
212
|
set rest to slice(rest, 4, length(rest))
|
|
163
|
-
|
|
213
|
+
let wrapped be ui.wrap(rest, width_now() - length(ind) - 2, "")
|
|
214
|
+
let wi be 0
|
|
215
|
+
each wl in wrapped
|
|
216
|
+
set out to append(out, pre + ind + (when wi == 0 then mark + " " otherwise " ") + inline(wl, color))
|
|
217
|
+
set wi to wi + 1
|
|
164
218
|
otherwise
|
|
165
|
-
|
|
219
|
+
when t == ""
|
|
220
|
+
set out to append(out, "")
|
|
221
|
+
otherwise
|
|
222
|
+
each wl in ui.wrap(trim(line), width_now(), "")
|
|
223
|
+
set out to append(out, pre + inline(wl, color))
|
|
166
224
|
when length(table) > 0
|
|
167
225
|
each l in table_lines(table, pre, color)
|
|
168
226
|
set out to append(out, l)
|
|
169
227
|
when in_code
|
|
170
|
-
set out to append(out, pre + sgr(color, "2", "└" + rep("─",
|
|
228
|
+
set out to append(out, pre + sgr(color, "2", "└" + rep("─", width_now() - 1)))
|
|
171
229
|
give join(out, "\n")
|
package/lib/permission.syn
CHANGED
|
@@ -114,6 +114,17 @@ export task evaluate(name, args, mode)
|
|
|
114
114
|
give {"decision": "deny", "reason": "strict mode (" + act + " MCP server)"}
|
|
115
115
|
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
116
|
give {"decision": "allow", "reason": "read-only tool"}
|
|
117
|
+
when name == "schedule"
|
|
118
|
+
-- programar = autorizar corridas futuras SIN nadie mirando (un comando, una lámpara, o el agente entero
|
|
119
|
+
-- con un sobre de permisos): humano siempre, incluso en yolo. list/log son lectura.
|
|
120
|
+
let sact be when contains(args, "action") then text(args["action"]) otherwise "list"
|
|
121
|
+
when sact == "list" or sact == "log"
|
|
122
|
+
give {"decision": "allow", "reason": "read-only tool"}
|
|
123
|
+
when mode == "strict"
|
|
124
|
+
give {"decision": "deny", "reason": "strict mode (schedule " + sact + ")"}
|
|
125
|
+
when sact == "add"
|
|
126
|
+
give {"decision": "ask", "reason": "schedules an unattended " + (when contains(args, "kind") then text(args["kind"]) otherwise "?") + " task" + (when contains(args, "permission") then " with permission=" + text(args["permission"]) otherwise " with permission=ask")}
|
|
127
|
+
give {"decision": "ask", "reason": "schedule " + sact + " " + (when contains(args, "id") then text(args["id"]) otherwise "?")}
|
|
117
128
|
when name == "skill" and is_install(args)
|
|
118
129
|
-- instala instrucciones/scripts de terceros FUERA del workspace (~/.agents/skills): humano siempre, incluso en yolo
|
|
119
130
|
when mode == "strict"
|
|
@@ -145,6 +156,13 @@ export task describe_call(name, args)
|
|
|
145
156
|
when act == "remove"
|
|
146
157
|
give "mcp remove " + (when contains(args, "name") then text(args["name"]) otherwise "?")
|
|
147
158
|
give "mcp list"
|
|
159
|
+
when name == "schedule"
|
|
160
|
+
let sact be arg(args, "action", "list")
|
|
161
|
+
when sact == "add"
|
|
162
|
+
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))
|
|
164
|
+
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
|
+
give "schedule " + sact + (when contains(args, "id") then " " + text(args["id"]) otherwise "")
|
|
148
166
|
when name == "skill" and is_install(args)
|
|
149
167
|
give "skill install " + text(args["source"]) + " --skill " + text(args["name"]) + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
|
|
150
168
|
when name == "bash"
|
package/lib/prompt.syn
CHANGED
|
@@ -35,7 +35,7 @@ Operate like a careful senior engineer: precise, honest, and economical with wor
|
|
|
35
35
|
# Method — follow these five steps on every non-trivial request
|
|
36
36
|
1. PLAN. If the task needs 3+ distinct steps (not 3 tool calls for one conceptual step), call todo FIRST and keep it current: one item in_progress at a time, marked completed the moment its work is verified — never batched, never on intent. The todo list survives context compaction; your narration does not. Skip it for a single straightforward change; when in doubt, use it.
|
|
37
37
|
2. LOCATE. Find code with grep and find, never by reading directories at random. For a broad, open-ended question about how the codebase works, delegate ONE focused question to an explore sub-agent instead of reading everything yourself — the search stays out of your context. Batch independent lookups into a single turn.
|
|
38
|
-
3. READ ONLY WHAT YOU NEED. Read the files you will change plus the ones you must understand to change them — not the whole project. Every file you read is re-sent on every later call. Use offset/limit for long files, but avoid tiny repeated slices. Trace a symbol to its definition and usages rather than guessing its shape. Never invent files, symbols, APIs or imports — if you have not seen it in this repo, go look. HARD LIMITS enforced by the harness: after
|
|
38
|
+
3. READ ONLY WHAT YOU NEED. Read the files you will change plus the ones you must understand to change them — not the whole project. Every file you read is re-sent on every later call. Use offset/limit for long files, but avoid tiny repeated slices. Trace a symbol to its definition and usages rather than guessing its shape. Never invent files, symbols, APIs or imports — if you have not seen it in this repo, go look. HARD LIMITS enforced by the harness: after 12 read-only calls in a row without acting you get a warning, after 24 read/ls/find/grep are refused until you act; a file read twice unchanged comes back as a one-line receipt.
|
|
39
39
|
4. CHANGE. Act as soon as you know enough — usually within the first 6-10 reads. Prefer edit (exact-string replacement; old_string must be unique, include surrounding context) over write; write only for new files or full rewrites. edit/write on an existing file are rejected unless you read that file in this session and it has not changed since. Minimal, targeted changes: no drive-by refactors, renames or reformatting; add every import or dependency your code needs. Do NOT print code blocks to the user as a substitute for editing — apply the change, then summarise it. If an edit fails to apply, re-read the file and retry with the exact current text; if the same region fails twice, rewrite the enclosing function with write.
|
|
40
40
|
5. VERIFY AND REPORT. Run the relevant tests, linter or build with bash and confirm they pass before claiming the work is done — AFTER your edits, not before as reconnaissance. Never assume a test command: check the README, the package manifest or the project instructions. Finish with a short summary: what changed (files, path:line for specific code) and how you verified it. Do not narrate every step.
|
|
41
41
|
|