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.
Files changed (49) hide show
  1. package/.env.example +9 -0
  2. package/README.md +60 -2
  3. package/chat.syn +264 -9
  4. package/lampson.ps1 +17 -3
  5. package/lampson.sh +17 -3
  6. package/lib/agents.syn +1 -1
  7. package/lib/approvals.syn +176 -0
  8. package/lib/diff.syn +37 -11
  9. package/lib/line.syn +13 -8
  10. package/lib/loop.syn +25 -11
  11. package/lib/md.syn +76 -18
  12. package/lib/permission.syn +18 -0
  13. package/lib/prompt.syn +1 -1
  14. package/lib/sched_run.syn +161 -0
  15. package/lib/schedule.syn +660 -0
  16. package/lib/session.syn +38 -1
  17. package/lib/settings.syn +55 -0
  18. package/lib/tools.syn +94 -2
  19. package/lib/ui.syn +161 -0
  20. package/package.json +1 -1
  21. package/public/css/chat.css +56 -0
  22. package/public/css/layout.css +97 -0
  23. package/public/css/panel.css +125 -0
  24. package/public/css/sidebar.css +80 -0
  25. package/public/css/tokens.css +57 -0
  26. package/public/index.html +49 -1187
  27. package/public/js/agents.js +31 -0
  28. package/public/js/app.js +21 -0
  29. package/public/js/approvals.js +26 -0
  30. package/public/js/chat.js +92 -0
  31. package/public/js/config.js +98 -0
  32. package/public/js/core.js +88 -0
  33. package/public/js/events.js +31 -0
  34. package/public/js/lamps.js +112 -0
  35. package/public/js/lsp.js +81 -0
  36. package/public/js/mcp.js +74 -0
  37. package/public/js/memory.js +17 -0
  38. package/public/js/panel.js +101 -0
  39. package/public/js/procs.js +45 -0
  40. package/public/js/schedules.js +118 -0
  41. package/public/js/sessions.js +69 -0
  42. package/public/js/sidebar.js +33 -0
  43. package/public/js/terminal.js +49 -0
  44. package/public/js/theme.js +6 -0
  45. package/public/js/todo.js +10 -0
  46. package/public/js/tree.js +53 -0
  47. package/public/js/update.js +14 -0
  48. package/skills/lampson/SKILL.md +16 -0
  49. 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 es enorme (> LIMIT² celdas) se muestra como bloque quitado + bloque agregado.
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 600
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
- let table be []
26
- let size be (n + 1) * (m + 1)
27
- while length(table) < size
28
- set table to append(table, 0)
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, sgr(color, "2", lpad(text(k + 1), width) + " │ " + a[k]))
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, sgr(color, "2", lpad(text(pre + o["b"] + 1), width) + " │ " + mid_b[o["b"]]))
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, sgr(color, "2", lpad(text(pre + o["a"] + 1), width) + " │ ") + sgr(color, "31", "- " + mid_a[o["a"]]))
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, sgr(color, "2", lpad(text(pre + o["b"] + 1), width) + " │ ") + sgr(color, "32", "+ " + mid_b[o["b"]]))
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, sgr(color, "2", lpad(text(k + 1), width) + " │ " + b[k]))
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 length(strip_ansi(s))
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) + length(l), cols)
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
- set rows_before_cursor to rows_before_cursor + floor((vis(pre) + cc) / cols)
111
- set cursor_col to (vis(pre) + cc) % cols
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
- let st be {"buf": "", "cur": 0, "prompt": prompt, "cursor_row": 0, "menu": false, "menu_off": false, "kind": "", "items": [], "sel": 0, "nav": false}
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
- set st to st
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 16
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
- give text(mcp.call(name, args))
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
- give text(lamps.call(name, args))
286
- let result be call_tool(registry[name], args)
287
- give text(result)
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
- give "ERROR: " + err
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
- when starts_with(out, "ERROR") or starts_with(out, "DENIED")
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
- set msgs to append(msgs, {"role": "assistant", "content": fin["text"], "tool_calls": []})
417
- emit(on_event, "assistant", fin["text"], tag)
418
- give {"messages": msgs, "text": fin["text"], "steps": steps, "usage": usage, "stopped": "too_many_errors"}
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
- let WIDTH be 72
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, "33", p)
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 length(inline(c, false)) > widths[i]
83
- set widths[i] to length(inline(c, false))
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
- let cells be []
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 fill be rep(" ", widths[i] - length(inline(c, false)))
92
- set cells to append(cells, (when ri == 0 then sgr(color, "1", inline(c, color)) otherwise inline(c, color)) + fill)
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
- set out to append(out, pre + join(cells, sgr(color, "2", " │ ")))
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("─", WIDTH - 3 - length(label)))
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("─", WIDTH - 1)))
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("─", WIDTH)))
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
- set out to append(out, pre + ind + mark + " " + inline(rest, color))
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
- set out to append(out, when t == "" then "" otherwise pre + inline(line, color))
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("─", WIDTH - 1)))
228
+ set out to append(out, pre + sgr(color, "2", "└" + rep("─", width_now() - 1)))
171
229
  give join(out, "\n")
@@ -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 8 read-only calls in a row without acting you get a warning, after 16 read/ls/find/grep are refused until you act; a file read twice unchanged comes back as a one-line receipt.
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