lampson 0.2.8 → 0.2.10
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 +10 -1
- package/README.md +8 -3
- package/bin/lampson.js +1 -1
- package/chat.syn +111 -7
- package/cli.syn +143 -7
- package/edge.syn +26 -0
- package/hub.tpl.syn +127 -6
- package/lampson.ps1 +43 -2
- package/lampson.sh +40 -2
- package/lib/agents.syn +27 -6
- package/lib/approvals.syn +2 -2
- package/lib/auth.syn +274 -0
- package/lib/computer.syn +2000 -0
- package/lib/computer_judge.syn +410 -0
- package/lib/fs.syn +19 -3
- package/lib/git.syn +2 -2
- package/lib/judge.syn +78 -0
- package/lib/line.syn +2 -2
- package/lib/loop.syn +36 -4
- package/lib/lsp.syn +4 -4
- package/lib/mcp.syn +20 -4
- package/lib/md.syn +11 -11
- package/lib/origin.syn +67 -0
- package/lib/permission.syn +21 -2
- package/lib/prompt.syn +9 -1
- package/lib/provider.syn +70 -20
- package/lib/schedule.syn +12 -12
- package/lib/session.syn +5 -5
- package/lib/settings.syn +65 -1
- package/lib/skills.syn +22 -6
- package/lib/tools/bash.syn +1 -1
- package/lib/tools/common.syn +1 -1
- package/lib/tools/fetch.syn +98 -71
- package/lib/tools/grep.syn +1 -1
- package/lib/tools/memo.syn +3 -3
- package/lib/tools/proc.syn +38 -20
- package/lib/tools/qr.js +26 -0
- package/lib/tools/url.syn +5 -5
- package/lib/tools.syn +23 -2
- package/lib/trace.syn +2 -2
- package/lib/tree.syn +29 -13
- package/lib/update.syn +1 -1
- package/lib/workspaces.syn +202 -36
- package/package.json +3 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/badge-96.png +0 -0
- package/public/css/code.css +48 -0
- package/public/css/hub.css +9 -0
- package/public/css/login.css +16 -0
- package/public/css/mobile.css +95 -0
- package/public/css/panel.css +30 -0
- package/public/css/tokens.css +1 -1
- package/public/hub.html +11 -1
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/icons-src/badge.svg +12 -0
- package/public/icons-src/icon-maskable.svg +15 -0
- package/public/icons-src/icon.svg +13 -0
- package/public/index.html +16 -3
- package/public/js/agents.js +1 -1
- package/public/js/chat.js +1 -1
- package/public/js/config.js +145 -2
- package/public/js/core.js +4 -2
- package/public/js/devices.js +80 -0
- package/public/js/editor.js +232 -0
- package/public/js/login.js +78 -0
- package/public/js/mobile.js +122 -0
- package/public/js/procs.js +1 -1
- package/public/js/pwa.js +5 -0
- package/public/js/terminal.js +1 -1
- package/public/js/tree.js +12 -4
- package/public/login.html +43 -0
- package/public/manifest.webmanifest +16 -0
- package/public/offline.html +24 -0
- package/public/sw.js +63 -0
- package/public/vendor/highlight.min.js +1343 -0
- package/public/vendor/qrcode.js +2297 -0
- package/skills/computer-use/SKILL.md +178 -0
- package/skills/lampson/SKILL.md +5 -0
- package/web.syn +67 -6
package/lib/loop.syn
CHANGED
|
@@ -27,6 +27,23 @@ use "./plugins.syn" as plugins
|
|
|
27
27
|
use "./tools/todo.syn" as todo
|
|
28
28
|
|
|
29
29
|
let MAX_ERRORS_PER_TURN be 8
|
|
30
|
+
-- capturas de computer_use (lib/computer.syn): el driver las deja en el blackboard "computer:screenshot" y acá van al
|
|
31
|
+
-- modelo como imagen adjunta a un mensaje de usuario (un tool result es texto en todos los proveedores). Solo la más
|
|
32
|
+
-- reciente viaja con la imagen; las anteriores quedan como nota (≈1500 tokens cada una, en CADA request).
|
|
33
|
+
let SHOT_PREFIX be "[harness] Screenshot from computer_use: "
|
|
34
|
+
|
|
35
|
+
export task drop_old_screenshots(msgs)
|
|
36
|
+
let out be []
|
|
37
|
+
each m in msgs
|
|
38
|
+
let is_shot be false
|
|
39
|
+
when m["role"] == "user"
|
|
40
|
+
when contains(m, "images")
|
|
41
|
+
set is_shot to starts_with(text(m["content"]), SHOT_PREFIX)
|
|
42
|
+
when is_shot
|
|
43
|
+
set out to append(out, {"role": "user", "content": text(m["content"]) + " [image removed to save context]"})
|
|
44
|
+
otherwise
|
|
45
|
+
set out to append(out, m)
|
|
46
|
+
give out
|
|
30
47
|
-- Conciencia de progreso (2026-08-28, la mejor idea de cada harness — ver notes/*.md):
|
|
31
48
|
-- * SPILL (deepseek): un resultado > SPILL_CAP se guarda entero en .lampson/spill/<call>.txt y el modelo ve
|
|
32
49
|
-- cabeza + cola + "Full result saved to …"; `read` queda exento (ya pagina con offset/limit).
|
|
@@ -107,7 +124,7 @@ export task spill(name, id, out)
|
|
|
107
124
|
let path be SPILL_DIR + "/" + id + ".txt"
|
|
108
125
|
write_file(path, out)
|
|
109
126
|
let half be floor(SPILL_CAP / 2)
|
|
110
|
-
give slice(out, 0, half) + "\n\n[... " + text(length(out) - SPILL_CAP) + " chars omitted. Full result saved to " + path + " — grep
|
|
127
|
+
give slice(out, 0, half) + "\n\n[... " + text(length(out) - SPILL_CAP) + " chars omitted. Full result saved to " + path + " — use the grep or read TOOLS on that exact path (it lives in lampson, outside your workspace: bash cannot see it); do not re-run the command ...]\n\n" + slice(out, length(out) - half, length(out))
|
|
111
128
|
|
|
112
129
|
-- recibo de una línea para un tool result viejo
|
|
113
130
|
export task receipt(m)
|
|
@@ -116,13 +133,13 @@ export task receipt(m)
|
|
|
116
133
|
give out
|
|
117
134
|
let status be "ok"
|
|
118
135
|
when starts_with(out, "ERROR") or starts_with(out, "DENIED")
|
|
119
|
-
set status to slice(
|
|
136
|
+
set status to slice(replace(split(out, "\n")[0], "\r", ""), 0, 120)
|
|
120
137
|
otherwise
|
|
121
|
-
let codes be
|
|
138
|
+
let codes be regex_find_all(out, "\\[exit code [0-9]+\\]")
|
|
122
139
|
when length(codes) > 0
|
|
123
140
|
let last be codes[length(codes) - 1]
|
|
124
141
|
set status to slice(last, 1, length(last) - 1)
|
|
125
|
-
let saved be
|
|
142
|
+
let saved be regex_find_all(out, "Full result saved to [^ ]+")
|
|
126
143
|
let where_full be when length(saved) > 0 then " · " + saved[0] otherwise ""
|
|
127
144
|
give "[" + text(m["name"]) + " result pruned: " + status + " · " + text(length(split(out, "\n"))) + " lines/" + text(length(out)) + " chars" + where_full + " — re-run the tool only if you really need the content again]"
|
|
128
145
|
|
|
@@ -324,6 +341,8 @@ export task run_turn(cfg, messages, opts, on_event)
|
|
|
324
341
|
let max_steps be opts["max_steps"]
|
|
325
342
|
let budget be opts["budget_tokens"]
|
|
326
343
|
let tag be opts["tag"]
|
|
344
|
+
-- ¿este modelo acepta imágenes? lo leen las tools que producen capturas (computer_use) para avisar al modelo
|
|
345
|
+
share provider.supports_vision(cfg) as "lampson:vision"
|
|
327
346
|
-- compaction preventiva
|
|
328
347
|
when estimate_tokens(msgs) > opts["compact_at_tokens"]
|
|
329
348
|
emit(on_event, "compact", {"before": estimate_tokens(msgs)}, tag)
|
|
@@ -376,6 +395,11 @@ export task run_turn(cfg, messages, opts, on_event)
|
|
|
376
395
|
emit(on_event, "usage", {"step": steps, "usage": r["usage"], "total": usage}, tag)
|
|
377
396
|
let clean_text be strip_raw_tool_calls(r["text"])
|
|
378
397
|
let assistant be {"role": "assistant", "content": clean_text, "tool_calls": r["tool_calls"]}
|
|
398
|
+
-- razonamiento del paso (solo lo trae el wire para los presets glm): va en el mensaje para
|
|
399
|
+
-- devolvérselo al modelo en el paso siguiente (thinking conservado, ver provider.chat_openai)
|
|
400
|
+
when contains(r, "reasoning")
|
|
401
|
+
when r["reasoning"] != ""
|
|
402
|
+
set assistant["reasoning"] to r["reasoning"]
|
|
379
403
|
set msgs to append(msgs, assistant)
|
|
380
404
|
when clean_text != ""
|
|
381
405
|
emit(on_event, "assistant", clean_text, tag)
|
|
@@ -422,6 +446,14 @@ export task run_turn(cfg, messages, opts, on_event)
|
|
|
422
446
|
set errors to errors + 1
|
|
423
447
|
emit(on_event, "tool_result", {"call": tc, "output": out}, tag)
|
|
424
448
|
set msgs to append(msgs, tool_result_msg(tc, out))
|
|
449
|
+
-- captura de pantalla pendiente (computer_use): adjunta como imagen si el proveedor tiene visión
|
|
450
|
+
observe "computer:screenshot" as shot
|
|
451
|
+
when shot != nothing
|
|
452
|
+
share nothing as "computer:screenshot"
|
|
453
|
+
when provider.supports_vision(cfg)
|
|
454
|
+
set msgs to drop_old_screenshots(msgs)
|
|
455
|
+
set msgs to append(msgs, {"role": "user", "content": SHOT_PREFIX + shot["label"] + ". Look at it, then continue the task.", "images": shot["images"]})
|
|
456
|
+
emit(on_event, "inbox", "captura de pantalla adjunta al modelo (" + shot["label"] + ")", tag)
|
|
425
457
|
when errors >= MAX_ERRORS_PER_TURN
|
|
426
458
|
set msgs to append(msgs, {"role": "user", "content": "[harness] Too many tool errors this turn. Stop calling tools and report what happened."})
|
|
427
459
|
emit(on_event, "busy", {"label": "pensando", "kind": "llm"}, tag)
|
package/lib/lsp.syn
CHANGED
|
@@ -92,13 +92,13 @@ export task servers()
|
|
|
92
92
|
-- resuelven realpaths, así que el rootUri y los uri de los documentos tienen que ser los reales.
|
|
93
93
|
export task root_path()
|
|
94
94
|
require env("LAMPSON_*")
|
|
95
|
-
let r be
|
|
95
|
+
let r be replace(env("LAMPSON_WORKSPACE", "workspace"), "\\", "/")
|
|
96
96
|
when c.ends_with(r, "/")
|
|
97
97
|
set r to slice(r, 0, length(r) - 1)
|
|
98
98
|
give r
|
|
99
99
|
|
|
100
100
|
export task to_uri(path)
|
|
101
|
-
let p be
|
|
101
|
+
let p be replace(replace(path, "\\", "/"), " ", "%20")
|
|
102
102
|
when matches(p, "[A-Za-z]:/.*")
|
|
103
103
|
give "file:///" + p
|
|
104
104
|
give "file://" + p
|
|
@@ -109,7 +109,7 @@ export task from_uri(uri)
|
|
|
109
109
|
set u to slice(u, 8, length(u))
|
|
110
110
|
otherwise when starts_with(u, "file://")
|
|
111
111
|
set u to slice(u, 7, length(u))
|
|
112
|
-
give
|
|
112
|
+
give replace(replace(replace(u, "%3A", ":"), "%3a", ":"), "%20", " ")
|
|
113
113
|
|
|
114
114
|
-- uri del server → ruta relativa al workspace (o absoluta si cae fuera)
|
|
115
115
|
export task relativize(uri, root)
|
|
@@ -123,7 +123,7 @@ export task relativize(uri, root)
|
|
|
123
123
|
give p
|
|
124
124
|
|
|
125
125
|
task ext_of(path)
|
|
126
|
-
let parts be split(
|
|
126
|
+
let parts be split(replace(path, "\\", "/"), "/")
|
|
127
127
|
let base be parts[length(parts) - 1]
|
|
128
128
|
let segs be split(base, ".")
|
|
129
129
|
when length(segs) < 2
|
package/lib/mcp.syn
CHANGED
|
@@ -109,6 +109,24 @@ agent McpSup
|
|
|
109
109
|
let sub be bus_subscribe(["mcp.req." + name, "mcp.stop." + name, "mcp.stop_all"])
|
|
110
110
|
-- request síncrona (handshake y tools/list): manda y espera la respuesta con ese id, saltando
|
|
111
111
|
-- notificaciones y basura; devuelve {result} | {error} | nothing (timeout / proceso muerto)
|
|
112
|
+
-- MIGA runtime (2026-09-06): proc_spawn en line_mode parte las líneas de más de 1 MiB en trozos de 1 MiB; un
|
|
113
|
+
-- resultado grande (una captura base64) llegaba en trozos que no decodificaban y la respuesta se perdía.
|
|
114
|
+
-- `acc` junta los trozos hasta que el JSON cierra; un trozo suelto que decodifica solo reinicia el acumulador.
|
|
115
|
+
let acc be ""
|
|
116
|
+
task decode_line(data)
|
|
117
|
+
let joined be acc + data
|
|
118
|
+
try
|
|
119
|
+
let m be json_decode(joined)
|
|
120
|
+
set acc to ""
|
|
121
|
+
give m
|
|
122
|
+
recover e1
|
|
123
|
+
try
|
|
124
|
+
let m2 be json_decode(data)
|
|
125
|
+
set acc to ""
|
|
126
|
+
give m2
|
|
127
|
+
recover e2
|
|
128
|
+
set acc to when length(joined) > 40000000 then "" otherwise joined
|
|
129
|
+
give nothing
|
|
112
130
|
task rpc(method, params, timeout)
|
|
113
131
|
set seq to seq + 1
|
|
114
132
|
let id be seq
|
|
@@ -122,12 +140,10 @@ agent McpSup
|
|
|
122
140
|
otherwise when ev["type"] == "exit"
|
|
123
141
|
set answer to {"error": "server exited with code " + text(ev["data"]["exit_code"])}
|
|
124
142
|
otherwise when ev["type"] == "stdout"
|
|
125
|
-
|
|
126
|
-
|
|
143
|
+
let m be decode_line(ev["data"])
|
|
144
|
+
when m != nothing
|
|
127
145
|
when contains(m, "id") and m["id"] == id
|
|
128
146
|
set answer to when contains(m, "error") then {"error": text(m["error"]["message"])} otherwise {"result": m["result"]}
|
|
129
|
-
recover e2
|
|
130
|
-
set answer to answer
|
|
131
147
|
give answer
|
|
132
148
|
task notify(method, params)
|
|
133
149
|
proc_send(p, json_encode({"jsonrpc": "2.0", "method": method, "params": params}) + "\n")
|
package/lib/md.syn
CHANGED
|
@@ -21,9 +21,9 @@ task width_now()
|
|
|
21
21
|
task paths(s, color)
|
|
22
22
|
when not color
|
|
23
23
|
give s
|
|
24
|
-
let out be
|
|
25
|
-
set out to
|
|
26
|
-
set out to
|
|
24
|
+
let out be regex_replace(s, "(^|[\s(])([\w.-]*/[\w./-]{2,})", "\1" + ESC + "[34m\2" + ESC + "[0m")
|
|
25
|
+
set out to regex_replace(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 regex_replace(out, "(^|[\s(])(\w+\(\))", "\1" + ESC + "[34m\2" + ESC + "[0m")
|
|
27
27
|
give out
|
|
28
28
|
|
|
29
29
|
task sgr(color, code, s)
|
|
@@ -42,16 +42,16 @@ task rep(ch, n)
|
|
|
42
42
|
task emphasis(s, color)
|
|
43
43
|
let out be s
|
|
44
44
|
-- links [texto](url) → texto url (antes que la cursiva: la url puede traer _ o *)
|
|
45
|
-
set out to
|
|
46
|
-
set out to
|
|
47
|
-
set out to
|
|
48
|
-
set out to
|
|
45
|
+
set out to regex_replace(out, "\[([^\]]+)\]\(([^)]+)\)", sgr(color, "4", "\1") + sgr(color, "2", " \2"))
|
|
46
|
+
set out to regex_replace(out, "\*\*([^*]+)\*\*", sgr(color, "1", "\1"))
|
|
47
|
+
set out to regex_replace(out, "__([^_]+)__", sgr(color, "1", "\1"))
|
|
48
|
+
set out to regex_replace(out, "~~([^~]+)~~", sgr(color, "9", "\1"))
|
|
49
49
|
-- cursiva: *x* solo si no está pegada a texto/número (2*3 no es cursiva). Dos pasadas:
|
|
50
50
|
-- el grupo \3 consume el separador, así que "*a* *b*" necesita la segunda.
|
|
51
51
|
let pass be 0
|
|
52
52
|
while pass < 2
|
|
53
|
-
set out to
|
|
54
|
-
set out to
|
|
53
|
+
set out to regex_replace(out, "(^|[^\w*])\*([^*\s][^*]*?)\*($|[^\w*])", "\1" + sgr(color, "3", "\2") + "\3")
|
|
54
|
+
set out to regex_replace(out, "(^|[^\w_])_([^_\s][^_]*?)_($|[^\w_])", "\1" + sgr(color, "3", "\2") + "\3")
|
|
55
55
|
set pass to pass + 1
|
|
56
56
|
give out
|
|
57
57
|
|
|
@@ -162,8 +162,8 @@ export task render(md, color)
|
|
|
162
162
|
let table be []
|
|
163
163
|
each line in split(md, "\n")
|
|
164
164
|
let t be trim(line)
|
|
165
|
-
let heading be
|
|
166
|
-
let item be
|
|
165
|
+
let heading be regex_capture(t, "^(#{1,6}) (.+)$")
|
|
166
|
+
let item be regex_capture(t, "^(\d{1,3})[.)] (.*)$")
|
|
167
167
|
let is_row be starts_with(t, "|") and ends_with(t, "|") and not in_code
|
|
168
168
|
when length(table) > 0 and not is_row
|
|
169
169
|
each l in table_lines(table, pre, color)
|
package/lib/origin.syn
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- lib/origin.syn — de dónde viene una petición HTTP: la usan el hub (hub.tpl.syn, lib/auth.syn) y cada workspace (web.syn).
|
|
2
|
+
--
|
|
3
|
+
-- «Local» (esta PC, entra sin passkey) se PRUEBA con las tres cosas a la vez, nunca se deduce de lo que falta:
|
|
4
|
+
-- 1. el peer es loopback;
|
|
5
|
+
-- 2. no pasó por ningún proxy (sin X-Forwarded-*, Forwarded ni los headers de cloudflared);
|
|
6
|
+
-- 3. el Host CRUDO es un nombre de loopback. Un túnel (syn tunnel, cloudflared, ngrok) o el edge de --domain
|
|
7
|
+
-- traen el Host público; una página con DNS rebinding trae el suyo.
|
|
8
|
+
-- Desde el motor v0.6.42 los X-Forwarded-* de un peer que no está en `trust proxy` se borran antes del programa. El
|
|
9
|
+
-- hub y los workspaces confían en loopback (`trust proxy` / `--trust-proxy`): ahí conectan los túneles, el edge y el
|
|
10
|
+
-- proxy del hub, así que lo que dicen llega y `ip of request` es el cliente real. Sin eso, un syn tunnel entraba
|
|
11
|
+
-- como «esta PC» (2026-10-05). Un túnel TCP crudo (ssh -R, ngrok tcp) no deja rastro y no se puede distinguir:
|
|
12
|
+
-- nunca se apunta uno al hub.
|
|
13
|
+
--
|
|
14
|
+
-- Otra origin en el navegador — CSRF, un WebSocket abierto desde otra página, DNS rebinding — se rechaza siempre,
|
|
15
|
+
-- local o remoto: un navegador manda Origin en todo POST, en todo WebSocket y en los fetch entre origins, y tiene
|
|
16
|
+
-- que coincidir con la origin de la propia petición. Sin Origin (curl, scripts, navegación) no hay sitio ajeno.
|
|
17
|
+
-- Módulo puro: sin require.
|
|
18
|
+
|
|
19
|
+
let FORWARD_HEADERS be ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded", "x-real-ip", "cf-connecting-ip", "cf-ray"]
|
|
20
|
+
let LOOPBACK_NAMES be ["localhost", "127.0.0.1", "::1"]
|
|
21
|
+
|
|
22
|
+
export task is_loopback(ip)
|
|
23
|
+
give ip == "127.0.0.1" or ip == "::1" or ip == "::ffff:127.0.0.1"
|
|
24
|
+
|
|
25
|
+
-- valor de Host (o X-Forwarded-Host) → nombre sin puerto ni corchetes, en minúsculas
|
|
26
|
+
export task host_name(value)
|
|
27
|
+
let h be trim(split(lower(text(value)), ",")[0])
|
|
28
|
+
when starts_with(h, "[")
|
|
29
|
+
give split(slice(h, 1, length(h)), "]")[0]
|
|
30
|
+
give split(h, ":")[0]
|
|
31
|
+
|
|
32
|
+
-- "host[:puerto]" como lo vio el navegador: el que puso el proxy de confianza, o el Host si no hubo proxy
|
|
33
|
+
task authority(headers)
|
|
34
|
+
give trim(split(lower(text(get(headers, "x-forwarded-host", get(headers, "host", "")))), ",")[0])
|
|
35
|
+
|
|
36
|
+
-- el Host que llegó a ESTE servidor (un proxy lo reescribe al suyo; el de afuera queda en X-Forwarded-Host)
|
|
37
|
+
export task raw_host(headers)
|
|
38
|
+
give host_name(get(headers, "host", ""))
|
|
39
|
+
|
|
40
|
+
-- el dominio que ve el usuario: rp_id de WebAuthn
|
|
41
|
+
export task public_host(headers)
|
|
42
|
+
give host_name(authority(headers))
|
|
43
|
+
|
|
44
|
+
-- la origin que ve el navegador. Lampson no termina TLS en el hub ni en los workspaces: sin un proxy de confianza
|
|
45
|
+
-- que diga https (el edge, un túnel), es http
|
|
46
|
+
export task origin_of(headers)
|
|
47
|
+
let proto be lower(trim(split(text(get(headers, "x-forwarded-proto", "")), ",")[0]))
|
|
48
|
+
when proto == ""
|
|
49
|
+
set proto to "http"
|
|
50
|
+
give proto + "://" + authority(headers)
|
|
51
|
+
|
|
52
|
+
export task forwarded(headers)
|
|
53
|
+
each h in FORWARD_HEADERS
|
|
54
|
+
when contains(headers, h)
|
|
55
|
+
give true
|
|
56
|
+
give false
|
|
57
|
+
|
|
58
|
+
export task loopback_host(headers)
|
|
59
|
+
give contains(LOOPBACK_NAMES, raw_host(headers))
|
|
60
|
+
|
|
61
|
+
export task cross_site(headers)
|
|
62
|
+
when not contains(headers, "origin")
|
|
63
|
+
give false
|
|
64
|
+
give lower(trim(text(headers["origin"]))) != origin_of(headers)
|
|
65
|
+
|
|
66
|
+
export task is_local(ip, headers)
|
|
67
|
+
give is_loopback(ip) and not forwarded(headers) and loopback_host(headers)
|
package/lib/permission.syn
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
-- Modos (LAMPSON_PERMISSION): "ask" (default) | "yolo" (dangerous → allow) | "strict" (dangerous → deny)
|
|
11
11
|
|
|
12
12
|
use "./tools/url.syn" as u
|
|
13
|
+
use "./computer.syn" as computer
|
|
13
14
|
|
|
14
15
|
-- Tier 1: nunca. Coincidencia por substring, case-insensitive.
|
|
15
16
|
-- (el comando se evalúa con un espacio final añadido, así "rm -rf / " matchea la raíz pero NO "rm -rf /tmp/x")
|
|
@@ -94,7 +95,7 @@ export task evaluate(name, args, mode)
|
|
|
94
95
|
give {"decision": "allow", "reason": "public URL"}
|
|
95
96
|
when name == "write" or name == "edit"
|
|
96
97
|
-- el scope file("./*") ya impide salir del workspace; aquí solo miramos secretos obvios
|
|
97
|
-
let p be when contains(args, "path") then lower(
|
|
98
|
+
let p be when contains(args, "path") then lower(replace(text(args["path"]), "\\", "/")) otherwise ""
|
|
98
99
|
let parts be split(p, "/")
|
|
99
100
|
let base be parts[length(parts) - 1]
|
|
100
101
|
when is_secret_env(base)
|
|
@@ -153,11 +154,27 @@ export task evaluate(name, args, mode)
|
|
|
153
154
|
when mode == "strict"
|
|
154
155
|
give {"decision": "deny", "reason": "strict mode (installs a third-party skill)"}
|
|
155
156
|
give {"decision": "ask", "reason": "installs a third-party skill from " + (when contains(args, "source") then text(args["source"]) otherwise "?") + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"}
|
|
157
|
+
when name == "computer_use"
|
|
158
|
+
-- el escritorio del usuario (lib/computer.syn): leer (ventanas, árbol, capturas, texto) siempre; actuar pide UNA
|
|
159
|
+
-- sola vez por corrida — la primera acción — y cubre todo lo demás (teclas en foreground, abrir apps y
|
|
160
|
+
-- navegadores): pedir por cada cosa hacía inútil la tool (2026-09-06: cuatro pedidos para llenar un form).
|
|
161
|
+
-- La tool deja "computer:grant" en el blackboard al ejecutar la primera aprobada. strict deniega actuar.
|
|
162
|
+
let cact be when contains(args, "action") then lower(text(args["action"])) otherwise ""
|
|
163
|
+
when not contains(computer.ACTING, cact)
|
|
164
|
+
give {"decision": "allow", "reason": "read-only tool"}
|
|
165
|
+
when mode == "yolo"
|
|
166
|
+
give {"decision": "allow", "reason": "yolo mode (computer " + cact + ")"}
|
|
167
|
+
when mode == "strict"
|
|
168
|
+
give {"decision": "deny", "reason": "strict mode (computer " + cact + ")"}
|
|
169
|
+
observe "computer:grant" as granted
|
|
170
|
+
when granted == true
|
|
171
|
+
give {"decision": "allow", "reason": "desktop control granted this run"}
|
|
172
|
+
give {"decision": "ask", "reason": "takes control of your desktop for the rest of this run — clicks and keys in the background (briefly to the front when an app needs it), opening apps and browser windows; asked once"}
|
|
156
173
|
give {"decision": "allow", "reason": "read-only tool"}
|
|
157
174
|
|
|
158
175
|
-- Resumen legible de una tool call, para mostrar al humano antes de aprobar / en el log.
|
|
159
176
|
task one_line(s, max)
|
|
160
|
-
let t be
|
|
177
|
+
let t be replace(replace(s, "\r", ""), "\n", " ⏎ ")
|
|
161
178
|
when length(t) > max
|
|
162
179
|
give slice(t, 0, max) + "…"
|
|
163
180
|
give t
|
|
@@ -165,6 +182,8 @@ task one_line(s, max)
|
|
|
165
182
|
export task describe_call(name, args)
|
|
166
183
|
when starts_with(name, "mcp_") or starts_with(name, "plugin_")
|
|
167
184
|
give name + " " + one_line(json_encode(args), 160)
|
|
185
|
+
when name == "computer_use"
|
|
186
|
+
give computer.describe(args)
|
|
168
187
|
when name == "lsp" and contains(args, "op") and text(args["op"]) == "add"
|
|
169
188
|
give "lsp add " + (when contains(args, "server") then text(args["server"]) otherwise "?") + " (" + (when contains(args, "scope") then text(args["scope"]) otherwise "global") + ")"
|
|
170
189
|
when name == "lsp"
|
package/lib/prompt.syn
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
use "./skills.syn" as skills
|
|
8
8
|
use "./tools/memo.syn" as memo
|
|
9
|
+
use "./computer.syn" as computer
|
|
9
10
|
|
|
10
11
|
let CONTEXT_FILES be ["AGENTS.md", "LAMPSON.md"]
|
|
11
12
|
|
|
@@ -22,13 +23,20 @@ task read_context_files()
|
|
|
22
23
|
give out
|
|
23
24
|
|
|
24
25
|
export task build(env_info, addendum)
|
|
26
|
+
require exec
|
|
27
|
+
require time
|
|
25
28
|
require file.read("workspace")
|
|
26
29
|
require file.read("workspace/*")
|
|
27
30
|
require file.read("skills")
|
|
28
31
|
require file.read("skills/*")
|
|
29
32
|
require file.read("memory")
|
|
30
33
|
require file.read("memory/*")
|
|
34
|
+
require file.read(".lampson")
|
|
35
|
+
require file.read(".lampson/*")
|
|
31
36
|
require env("LAMPSON_*")
|
|
37
|
+
require env("OS")
|
|
38
|
+
-- computer_use solo se menciona cuando está en el catálogo (encendida + driver): vacío si no
|
|
39
|
+
let cu be computer.prompt_line()
|
|
32
40
|
let core be `You are Lampson, a coding agent that works inside the user's project through tools.
|
|
33
41
|
Operate like a careful senior engineer: precise, honest, and economical with words.
|
|
34
42
|
|
|
@@ -60,7 +68,7 @@ Operate like a careful senior engineer: precise, honest, and economical with wor
|
|
|
60
68
|
- 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).
|
|
61
69
|
- 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.
|
|
62
70
|
- todo: your task list (whole-list replacement, one in_progress at a time; see Method step 1).
|
|
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.
|
|
71
|
+
- 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.{cu}
|
|
64
72
|
- Call several independent tools in one turn (reads, searches, read-only commands); serialize only when a later call depends on an earlier result. Tool outputs are truncated at 30k chars — narrow your queries.
|
|
65
73
|
|
|
66
74
|
# Tone
|
package/lib/provider.syn
CHANGED
|
@@ -37,15 +37,39 @@ export let PRESETS be {
|
|
|
37
37
|
"ollama": {"wire": "openai", "base_url": "http://127.0.0.1:11434/v1", "model": "qwen2.5-coder"},
|
|
38
38
|
"anthropic": {"wire": "anthropic", "base_url": "https://api.anthropic.com/v1", "model": "claude-sonnet-4-6"},
|
|
39
39
|
"minimax": {"wire": "anthropic", "base_url": "https://api.minimax.io/anthropic/v1", "model": "MiniMax-M3"},
|
|
40
|
-
-- Z.ai (Zhipu)
|
|
41
|
-
|
|
40
|
+
-- Z.ai (Zhipu), API compatible con OpenAI, dos presets con la MISMA key (es la misma cuenta):
|
|
41
|
+
-- `glm` = pago por uso (saldo prepago); `glm-coding` = GLM Coding Plan (suscripción), que solo vale
|
|
42
|
+
-- en el endpoint /coding/. La key de un Coding Plan contra `glm` da 429 código 1113 "Insufficient
|
|
43
|
+
-- balance or no resource package" (visto 2026-09-05): no es la key, es el endpoint.
|
|
44
|
+
-- Modelo por defecto glm-5.3-flash (2026-09-07): el primer GLM-5 multimodal nativo (imágenes por
|
|
45
|
+
-- image_url, verificado con un PNG en data URL), 1M de contexto, 128K de salida y más rápido que
|
|
46
|
+
-- glm-5.3; ambos endpoints lo listan en GET /models. Su thinking no se puede apagar (ver chat_openai).
|
|
47
|
+
"glm": {"wire": "openai", "base_url": "https://api.z.ai/api/paas/v4", "model": "glm-5.3-flash"},
|
|
48
|
+
"glm-coding": {"wire": "openai", "base_url": "https://api.z.ai/api/coding/paas/v4", "model": "glm-5.3-flash"}
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
-- Keys: una por proveedor en .env como LAMPSON_API_KEY_<PROVIDER> (LAMPSON_API_KEY_DEEPSEEK, _ANTHROPIC…);
|
|
45
52
|
-- LAMPSON_API_KEY es la del proveedor por defecto (LAMPSON_PROVIDER). Así se cambia de proveedor en caliente
|
|
46
53
|
-- (/provider en terminal, selector en la web) sin tocar .env. Solo se comprueba SI hay key, nunca se muestra.
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
-- (`glm-coding` → LAMPSON_API_KEY_GLM_CODING: un guion no es válido en un nombre de variable)
|
|
55
|
+
export task key_var(name)
|
|
56
|
+
give "LAMPSON_API_KEY_" + upper(join(split(name, "-"), "_"))
|
|
57
|
+
|
|
58
|
+
-- Presets que comparten cuenta y key (z.ai): si uno no tiene key propia, usa la del otro.
|
|
59
|
+
let KEY_ALIASES be {"glm-coding": "glm", "glm": "glm-coding"}
|
|
60
|
+
|
|
61
|
+
-- Nombre bajo el que hay key para `n` (config.json o LAMPSON_API_KEY_<P>): él mismo, si no su alias, si no "".
|
|
62
|
+
task key_owner(n)
|
|
63
|
+
require env("LAMPSON_*")
|
|
64
|
+
require file.read(".lampson")
|
|
65
|
+
require file.read(".lampson/*")
|
|
66
|
+
when settings.raw_key(n) != "" or env(key_var(n), "") != ""
|
|
67
|
+
give n
|
|
68
|
+
when contains(KEY_ALIASES, n)
|
|
69
|
+
let a be KEY_ALIASES[n]
|
|
70
|
+
when settings.raw_key(a) != "" or env(key_var(a), "") != ""
|
|
71
|
+
give a
|
|
72
|
+
give ""
|
|
49
73
|
|
|
50
74
|
-- proveedor por defecto: config.json (onboarding / UI) > LAMPSON_PROVIDER en .env > deepseek
|
|
51
75
|
task env_default()
|
|
@@ -67,9 +91,7 @@ export task has_key(name)
|
|
|
67
91
|
require file.read(".lampson/*")
|
|
68
92
|
when name == "ollama"
|
|
69
93
|
give true
|
|
70
|
-
when
|
|
71
|
-
give true
|
|
72
|
-
when env(key_var(name), "") != ""
|
|
94
|
+
when key_owner(name) != ""
|
|
73
95
|
give true
|
|
74
96
|
give name == env_default() and env("LAMPSON_API_KEY", "") != ""
|
|
75
97
|
|
|
@@ -97,7 +119,7 @@ export task providers()
|
|
|
97
119
|
-- Nombres de modelo: DeepSeek, OpenAI, Anthropic, Groq, Kimi, Grok y OpenRouter publican ids en minúsculas
|
|
98
120
|
-- y la API rechaza otra capitalización ("deepseek-v4-pro" sí, "DeepSeek-V4-Pro" → 400 invalid_request_error,
|
|
99
121
|
-- visto 2026-08-27 con un nombre tipeado en /setup). MiniMax ("MiniMax-M3") y Ollama (tags) se dejan tal cual.
|
|
100
|
-
let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter", "glm"]
|
|
122
|
+
let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter", "glm", "glm-coding"]
|
|
101
123
|
|
|
102
124
|
export task normalize_model(name, model)
|
|
103
125
|
let m be trim(text(model))
|
|
@@ -163,12 +185,15 @@ export task config_for(name, model)
|
|
|
163
185
|
when model != nothing and model != ""
|
|
164
186
|
set m to model
|
|
165
187
|
set m to normalize_model(n, m)
|
|
166
|
-
-- key: guardada en config.json > LAMPSON_API_KEY_<P> > LAMPSON_API_KEY (solo para el proveedor de .env)
|
|
188
|
+
-- key: guardada en config.json > LAMPSON_API_KEY_<P> > LAMPSON_API_KEY (solo para el proveedor de .env).
|
|
189
|
+
-- Si el preset no tiene key propia se usa la de su alias (KEY_ALIASES: glm ↔ glm-coding).
|
|
167
190
|
let key be secret("LAMPSON_API_KEY", "ollama")
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
191
|
+
let ko be key_owner(n)
|
|
192
|
+
when ko != ""
|
|
193
|
+
when env(key_var(ko), "") != ""
|
|
194
|
+
set key to secret(key_var(ko), "ollama")
|
|
195
|
+
when settings.raw_key(ko) != ""
|
|
196
|
+
set key to as_secret(settings.raw_key(ko), "api_key_" + ko)
|
|
172
197
|
give {
|
|
173
198
|
"provider": n,
|
|
174
199
|
"wire": wire,
|
|
@@ -200,7 +225,15 @@ task err_result(msg)
|
|
|
200
225
|
|
|
201
226
|
-- ---------- OpenAI wire (chat/completions) ----------
|
|
202
227
|
|
|
203
|
-
|
|
228
|
+
-- Presets de Z.ai: comparten cuenta y key, y son los únicos que reciben `thinking` y el eco del razonamiento.
|
|
229
|
+
task is_glm(cfg)
|
|
230
|
+
when not contains(cfg, "provider")
|
|
231
|
+
give false
|
|
232
|
+
give cfg["provider"] == "glm" or cfg["provider"] == "glm-coding"
|
|
233
|
+
|
|
234
|
+
-- keep_reasoning: devolver el `reasoning` guardado en cada assistant como `reasoning_content` (solo Z.ai;
|
|
235
|
+
-- otros endpoints podrían rechazar el campo). Ver chat_openai.
|
|
236
|
+
task to_openai_messages(messages, keep_reasoning)
|
|
204
237
|
let out be []
|
|
205
238
|
each m in messages
|
|
206
239
|
when m["role"] == "assistant"
|
|
@@ -211,6 +244,10 @@ task to_openai_messages(messages)
|
|
|
211
244
|
each tc in m["tool_calls"]
|
|
212
245
|
set tcs to append(tcs, {"id": tc["id"], "type": "function", "function": {"name": tc["name"], "arguments": json_encode(tc["args"])}})
|
|
213
246
|
set entry to {"role": "assistant", "content": m["content"], "tool_calls": tcs}
|
|
247
|
+
when keep_reasoning and contains(m, "reasoning")
|
|
248
|
+
when m["reasoning"] != nothing
|
|
249
|
+
when m["reasoning"] != ""
|
|
250
|
+
set entry["reasoning_content"] to m["reasoning"]
|
|
214
251
|
set out to append(out, entry)
|
|
215
252
|
otherwise when m["role"] == "tool"
|
|
216
253
|
set out to append(out, {"role": "tool", "tool_call_id": m["tool_call_id"], "content": m["content"]})
|
|
@@ -257,9 +294,10 @@ export task supports_vision(cfg)
|
|
|
257
294
|
give contains(m, "vision") or starts_with(m, "grok-4")
|
|
258
295
|
when p == "kimi"
|
|
259
296
|
give contains(m, "vision") or contains(m, "vl")
|
|
260
|
-
-- GLM: los modelos con visión llevan sufijo v (glm-4.5v, glm-4.6v)
|
|
261
|
-
|
|
262
|
-
|
|
297
|
+
-- GLM: los modelos con visión llevan sufijo v (glm-4.5v, glm-4.6v); glm-5.3-flash es el primer GLM-5
|
|
298
|
+
-- multimodal nativo (glm-5.3 a secas NO acepta imágenes). Verificado 2026-09-07 con un PNG por data URL.
|
|
299
|
+
when p == "glm" or p == "glm-coding"
|
|
300
|
+
give contains(m, "vision") or ends_with(m, "v") or contains(m, "v-") or starts_with(m, "glm-5.3-flash")
|
|
263
301
|
give false
|
|
264
302
|
|
|
265
303
|
-- Modelo sin visión: la imagen se reemplaza por una nota que el modelo puede explicarle al usuario
|
|
@@ -311,9 +349,16 @@ task parse_args(raw)
|
|
|
311
349
|
give {"_raw": raw, "_parse_error": err}
|
|
312
350
|
|
|
313
351
|
task chat_openai(cfg, messages, catalog)
|
|
314
|
-
let
|
|
352
|
+
let glm be is_glm(cfg)
|
|
353
|
+
let body be {"model": cfg["model"], "messages": to_openai_messages(messages, glm), "max_tokens": cfg["max_tokens"]}
|
|
315
354
|
when length(catalog) > 0
|
|
316
|
-
set body
|
|
355
|
+
set body["tools"] to to_openai_tools(catalog)
|
|
356
|
+
-- Z.ai: el thinking de glm-5.3-flash no se puede apagar y la doc recomienda clear_thinking=false, que
|
|
357
|
+
-- conserva el razonamiento entre pasos de un turno SI cada assistant vuelve con su reasoning_content
|
|
358
|
+
-- (por eso se guarda `reasoning` en el mensaje y to_openai_messages lo ecoa). Solo para los presets
|
|
359
|
+
-- glm: otros endpoints OpenAI rechazan parámetros desconocidos. Verificado 2026-09-07: 200 con y sin eco.
|
|
360
|
+
when glm
|
|
361
|
+
set body["thinking"] to {"type": "enabled", "clear_thinking": false}
|
|
317
362
|
let headers be {"Authorization": bearer(cfg["key"]), "Content-Type": "application/json"}
|
|
318
363
|
let r be http_post(strip_slash(cfg["base_url"]) + "/chat/completions", json_encode(body), headers, cfg["timeout"])
|
|
319
364
|
when contains(r, "error")
|
|
@@ -331,6 +376,11 @@ task chat_openai(cfg, messages, catalog)
|
|
|
331
376
|
when msg["tool_calls"] != nothing
|
|
332
377
|
each tc in msg["tool_calls"]
|
|
333
378
|
set calls to append(calls, {"id": tc["id"], "name": tc["function"]["name"], "args": parse_args(tc["function"]["arguments"])})
|
|
379
|
+
-- razonamiento del paso (reasoning_content): se guarda solo para Z.ai, que es quien lo vuelve a pedir
|
|
380
|
+
let reasoning be ""
|
|
381
|
+
when glm and contains(msg, "reasoning_content")
|
|
382
|
+
when msg["reasoning_content"] != nothing
|
|
383
|
+
set reasoning to text(msg["reasoning_content"])
|
|
334
384
|
let usage be {"input": 0, "output": 0, "cached": 0}
|
|
335
385
|
when contains(j, "usage")
|
|
336
386
|
when j["usage"] != nothing
|
|
@@ -348,7 +398,7 @@ task chat_openai(cfg, messages, catalog)
|
|
|
348
398
|
give {"text": "", "tool_calls": [], "usage": usage, "stop": "end", "error": "empty response: the model used its whole output budget (max_tokens=" + text(cfg["max_tokens"]) + ") without emitting text or tool calls — a reasoning model can spend it all thinking. Raise LAMPSON_MAX_TOKENS (or simplify the request) and say «seguí»."}
|
|
349
399
|
set txt to txt + "\n\n[respuesta cortada: se alcanzó max_tokens=" + text(cfg["max_tokens"]) + " — decí «seguí» para que continúe]"
|
|
350
400
|
let finish be when length(calls) > 0 then "tool_calls" otherwise "end"
|
|
351
|
-
give {"text": txt, "tool_calls": calls, "usage": usage, "stop": finish, "error": nothing}
|
|
401
|
+
give {"text": txt, "tool_calls": calls, "usage": usage, "stop": finish, "error": nothing, "reasoning": reasoning}
|
|
352
402
|
|
|
353
403
|
-- ---------- Anthropic wire (messages) ----------
|
|
354
404
|
|
package/lib/schedule.syn
CHANGED
|
@@ -70,7 +70,7 @@ export task tz_offset()
|
|
|
70
70
|
|
|
71
71
|
-- "+0200" | "-03:00" | "-3" → segundos
|
|
72
72
|
export task parse_offset(raw)
|
|
73
|
-
let s be
|
|
73
|
+
let s be replace(trim(raw), ":", "")
|
|
74
74
|
when s == ""
|
|
75
75
|
give 0
|
|
76
76
|
let sign be when starts_with(s, "-") then -1 otherwise 1
|
|
@@ -126,8 +126,8 @@ task parse_span(tok)
|
|
|
126
126
|
let t be lower(tok)
|
|
127
127
|
when not matches(t, "[0-9]+(\.[0-9]+)?(s|m|h|d|min|h?ora?s?|d[ií]as?|seg(undos)?|minutos?)?")
|
|
128
128
|
give nothing
|
|
129
|
-
let num be number(
|
|
130
|
-
let unit be
|
|
129
|
+
let num be number(regex_capture(t, "[0-9]+(?:\.[0-9]+)?")[0])
|
|
130
|
+
let unit be regex_replace(t, "^[0-9.]+", "")
|
|
131
131
|
when unit == "s" or starts_with(unit, "seg")
|
|
132
132
|
give floor(num)
|
|
133
133
|
when unit == "m" or starts_with(unit, "min")
|
|
@@ -139,9 +139,9 @@ task parse_span(tok)
|
|
|
139
139
|
-- → {ok, plan: {kind: every|daily|weekly, seconds?, hh?, mm?, days?}, error}
|
|
140
140
|
export task parse_when(spec)
|
|
141
141
|
let s be lower(trim(text(spec)))
|
|
142
|
-
set s to
|
|
143
|
-
set s to
|
|
144
|
-
let toks be where(split(
|
|
142
|
+
set s to replace(replace(replace(s, " a las ", " "), " at ", " "), "todos los días", "daily")
|
|
143
|
+
set s to replace(replace(s, "todos los dias", "daily"), "every day", "daily")
|
|
144
|
+
let toks be where(split(replace(s, ",", " "), " "), (x) => x != "")
|
|
145
145
|
when length(toks) == 0
|
|
146
146
|
give {"ok": false, "error": "empty schedule"}
|
|
147
147
|
let head be toks[0]
|
|
@@ -335,7 +335,7 @@ export task mount_ok()
|
|
|
335
335
|
give true
|
|
336
336
|
|
|
337
337
|
task norm_path(p)
|
|
338
|
-
let s be lower(
|
|
338
|
+
let s be lower(replace(trim(p), "\\", "/"))
|
|
339
339
|
-- Git Bash: /c/Users/x → c:/users/x
|
|
340
340
|
when matches(s, "/[a-z]/.*")
|
|
341
341
|
set s to slice(s, 1, 2) + ":" + slice(s, 2, length(s))
|
|
@@ -369,8 +369,8 @@ task put(t)
|
|
|
369
369
|
give t
|
|
370
370
|
|
|
371
371
|
task slug(name)
|
|
372
|
-
let s be lower(
|
|
373
|
-
set s to
|
|
372
|
+
let s be lower(regex_replace(text(name), "[^A-Za-z0-9]+", "-"))
|
|
373
|
+
set s to regex_replace(regex_replace(s, "^-+", ""), "-+$", "")
|
|
374
374
|
when s == ""
|
|
375
375
|
give "task"
|
|
376
376
|
when length(s) > 24
|
|
@@ -471,7 +471,7 @@ task default_name(action)
|
|
|
471
471
|
when action["type"] == "bash"
|
|
472
472
|
let cmd be action["command"]
|
|
473
473
|
give when length(cmd) > 40 then slice(cmd, 0, 40) + "…" otherwise cmd
|
|
474
|
-
let p be
|
|
474
|
+
let p be replace(action["prompt"], "\n", " ")
|
|
475
475
|
give when length(p) > 40 then slice(p, 0, 40) + "…" otherwise p
|
|
476
476
|
|
|
477
477
|
export task remove(id)
|
|
@@ -628,7 +628,7 @@ export task record(t, started, status, output, extra)
|
|
|
628
628
|
give fresh
|
|
629
629
|
|
|
630
630
|
task one_line(s, max)
|
|
631
|
-
let t be trim(
|
|
631
|
+
let t be trim(replace(replace(text(s), "\r", ""), "\n", " ⏎ "))
|
|
632
632
|
when length(t) > max
|
|
633
633
|
give slice(t, 0, max) + "…"
|
|
634
634
|
give t
|
|
@@ -646,7 +646,7 @@ export task log_tail(id, n)
|
|
|
646
646
|
when not matches(text(id), "[a-z0-9-]{1,40}")
|
|
647
647
|
give ""
|
|
648
648
|
try
|
|
649
|
-
let lines be split(
|
|
649
|
+
let lines be split(replace(read_file(LOG_DIR + "/" + id + ".log"), "\r", ""), "\n")
|
|
650
650
|
when length(lines) > n
|
|
651
651
|
set lines to slice(lines, length(lines) - n, length(lines))
|
|
652
652
|
give join(lines, "\n")
|