lampson 0.1.0

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 (62) hide show
  1. package/.env.example +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +382 -0
  4. package/bin/lampson.js +81 -0
  5. package/chat.syn +799 -0
  6. package/lamps/example-hello/lamp.json +16 -0
  7. package/lamps/example-hello/lamp.syn +19 -0
  8. package/lampson.cmd +4 -0
  9. package/lampson.ps1 +88 -0
  10. package/lampson.sh +42 -0
  11. package/lib/agents.syn +471 -0
  12. package/lib/git.syn +58 -0
  13. package/lib/lamps.syn +386 -0
  14. package/lib/loop.syn +455 -0
  15. package/lib/lsp.syn +503 -0
  16. package/lib/mcp.syn +403 -0
  17. package/lib/permission.syn +154 -0
  18. package/lib/prompt.syn +75 -0
  19. package/lib/provider.syn +522 -0
  20. package/lib/session.syn +111 -0
  21. package/lib/settings.syn +70 -0
  22. package/lib/skills.syn +179 -0
  23. package/lib/tools/bash.syn +105 -0
  24. package/lib/tools/common.sh +49 -0
  25. package/lib/tools/common.syn +91 -0
  26. package/lib/tools/edit.syn +32 -0
  27. package/lib/tools/find.syn +35 -0
  28. package/lib/tools/grep.syn +31 -0
  29. package/lib/tools/img.ps1 +36 -0
  30. package/lib/tools/img.sh +22 -0
  31. package/lib/tools/ls.syn +18 -0
  32. package/lib/tools/memo.syn +148 -0
  33. package/lib/tools/proc.sh +42 -0
  34. package/lib/tools/proc.syn +314 -0
  35. package/lib/tools/process.syn +46 -0
  36. package/lib/tools/read.syn +25 -0
  37. package/lib/tools/skill.syn +14 -0
  38. package/lib/tools/todo.syn +97 -0
  39. package/lib/tools/write.syn +22 -0
  40. package/lib/tools.syn +198 -0
  41. package/lib/trace.syn +116 -0
  42. package/lib/tree.syn +59 -0
  43. package/lib/update.syn +57 -0
  44. package/package.json +40 -0
  45. package/public/fonts/plex-mono-400-latin-ext.woff2 +0 -0
  46. package/public/fonts/plex-mono-400-latin.woff2 +0 -0
  47. package/public/fonts/plex-mono-600-latin-ext.woff2 +0 -0
  48. package/public/fonts/plex-mono-600-latin.woff2 +0 -0
  49. package/public/fonts/plex-serif-400-latin-ext.woff2 +0 -0
  50. package/public/fonts/plex-serif-400-latin.woff2 +0 -0
  51. package/public/fonts/plex-serif-400i-latin-ext.woff2 +0 -0
  52. package/public/fonts/plex-serif-400i-latin.woff2 +0 -0
  53. package/public/fonts/plex-serif-600-latin-ext.woff2 +0 -0
  54. package/public/fonts/plex-serif-600-latin.woff2 +0 -0
  55. package/public/index.html +1268 -0
  56. package/public/vendor/xterm-addon-fit.js +2 -0
  57. package/public/vendor/xterm.css +218 -0
  58. package/public/vendor/xterm.js +2 -0
  59. package/skills/debugging/SKILL.md +33 -0
  60. package/skills/lampson/SKILL.md +117 -0
  61. package/skills/synsema/SKILL.md +75 -0
  62. package/web.syn +468 -0
@@ -0,0 +1,522 @@
1
+ -- lib/provider.syn — transporte HTTP hacia LLMs (OpenAI-compatible y Anthropic-compatible)
2
+ --
3
+ -- Filosofía: NO usamos SDKs ni el provider built-in de
4
+ -- Synsema (`llm_step`), hablamos HTTP crudo. Ventajas: historial multi-turno real, tool calling
5
+ -- nativo de cada wire protocol, cualquier endpoint compatible (DeepSeek, Kimi, Groq, Ollama,
6
+ -- MiniMax, Anthropic, OpenRouter...) solo cambiando LAMPSON_BASE_URL.
7
+ --
8
+ -- Formato CANÓNICO de mensajes (interno, estilo OpenAI):
9
+ -- {role: "system"|"user"|"assistant"|"tool", content: text,
10
+ -- tool_calls?: [{id, name, args(map)}], -- solo assistant
11
+ -- tool_call_id?: text, name?: text} -- solo tool
12
+ --
13
+ -- chat(cfg, messages, catalog) → {text, tool_calls: [{id,name,args}], usage: {input, output}, stop, error}
14
+ --
15
+ -- MIGAS del runtime (verificadas en v0.6.7):
16
+ -- * http_post con un MAP como body lo serializa con text() (NO JSON): siempre json_encode(body)
17
+ -- + header Content-Type: application/json.
18
+ -- * `json of r` NO existe en la respuesta; usar json_decode(body of r). Claves: status, ok, body, headers.
19
+ -- * En error de red: status 0 y clave `error`; en éxito no hay clave `error` → contains(r, "error").
20
+ -- * `localhost` resuelve a IPv6 en Windows; usar 127.0.0.1 para servers locales.
21
+ -- * secret() como valor de header llega materializado al socket y redactado en logs.
22
+ -- * Bajo `serve`, secret() debe resolverse DENTRO del handler (los globals cruzan redactados).
23
+ -- * number("4096") es FLOAT → json_encode emite 4096.0 y las APIs lo rechazan ("invalid params"): floor() para enteros.
24
+
25
+ use "./settings.syn" as settings
26
+
27
+ let ANTHROPIC_VERSION be "2023-06-01"
28
+
29
+ -- Tabla base URL por proveedor
30
+ export let PRESETS be {
31
+ "openai": {"wire": "openai", "base_url": "https://api.openai.com/v1", "model": "gpt-4o"},
32
+ "deepseek": {"wire": "openai", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-v4-flash"},
33
+ "kimi": {"wire": "openai", "base_url": "https://api.moonshot.ai/v1", "model": "kimi-k2-0711-preview"},
34
+ "groq": {"wire": "openai", "base_url": "https://api.groq.com/openai/v1", "model": "llama-3.3-70b-versatile"},
35
+ "grok": {"wire": "openai", "base_url": "https://api.x.ai/v1", "model": "grok-3"},
36
+ "openrouter": {"wire": "openai", "base_url": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-chat"},
37
+ "ollama": {"wire": "openai", "base_url": "http://127.0.0.1:11434/v1", "model": "qwen2.5-coder"},
38
+ "anthropic": {"wire": "anthropic", "base_url": "https://api.anthropic.com/v1", "model": "claude-sonnet-4-6"},
39
+ "minimax": {"wire": "anthropic", "base_url": "https://api.minimax.io/anthropic/v1", "model": "MiniMax-M3"}
40
+ }
41
+
42
+ -- Keys: una por proveedor en .env como LAMPSON_API_KEY_<PROVIDER> (LAMPSON_API_KEY_DEEPSEEK, _ANTHROPIC…);
43
+ -- LAMPSON_API_KEY es la del proveedor por defecto (LAMPSON_PROVIDER). Así se cambia de proveedor en caliente
44
+ -- (/provider en terminal, selector en la web) sin tocar .env. Solo se comprueba SI hay key, nunca se muestra.
45
+ task key_var(name)
46
+ give "LAMPSON_API_KEY_" + upper(name)
47
+
48
+ -- proveedor por defecto: config.json (onboarding / UI) > LAMPSON_PROVIDER en .env > deepseek
49
+ task env_default()
50
+ give lower(env("LAMPSON_PROVIDER", "deepseek"))
51
+
52
+ export task default_provider()
53
+ require env("LAMPSON_*")
54
+ require file.read(".lampson")
55
+ require file.read(".lampson/*")
56
+ let s be settings.load()
57
+ when s["provider"] != ""
58
+ when contains(PRESETS, lower(s["provider"]))
59
+ give lower(s["provider"])
60
+ give env_default()
61
+
62
+ export task has_key(name)
63
+ require env("LAMPSON_*")
64
+ require file.read(".lampson")
65
+ require file.read(".lampson/*")
66
+ when name == "ollama"
67
+ give true
68
+ when settings.raw_key(name) != ""
69
+ give true
70
+ when env(key_var(name), "") != ""
71
+ give true
72
+ give name == env_default() and env("LAMPSON_API_KEY", "") != ""
73
+
74
+ -- ¿hay algún proveedor usable? (si no, la UI/terminal muestran el onboarding)
75
+ export task configured()
76
+ require env("LAMPSON_*")
77
+ require file.read(".lampson")
78
+ require file.read(".lampson/*")
79
+ each n in keys(PRESETS)
80
+ when n != "ollama" and has_key(n)
81
+ give true
82
+ give false
83
+
84
+ -- [{name, model, wire, has_key, current}] para selectores
85
+ export task providers()
86
+ require env("LAMPSON_*")
87
+ require file.read(".lampson")
88
+ require file.read(".lampson/*")
89
+ let cur be default_provider()
90
+ let out be []
91
+ each n in sort_by(keys(PRESETS), (x) => x)
92
+ set out to append(out, {"name": n, "model": PRESETS[n]["model"], "wire": PRESETS[n]["wire"], "has_key": has_key(n), "current": n == cur})
93
+ give out
94
+
95
+ -- Nombres de modelo: DeepSeek, OpenAI, Anthropic, Groq, Kimi, Grok y OpenRouter publican ids en minúsculas
96
+ -- y la API rechaza otra capitalización ("deepseek-v4-pro" sí, "DeepSeek-V4-Pro" → 400 invalid_request_error,
97
+ -- visto 2026-08-27 con un nombre tipeado en /setup). MiniMax ("MiniMax-M3") y Ollama (tags) se dejan tal cual.
98
+ let LOWERCASE_MODELS be ["deepseek", "openai", "anthropic", "groq", "kimi", "grok", "openrouter"]
99
+
100
+ export task normalize_model(name, model)
101
+ let m be trim(text(model))
102
+ when contains(LOWERCASE_MODELS, name)
103
+ give lower(m)
104
+ give m
105
+
106
+ -- Modelos que la API declara disponibles (GET {base_url}/models, ambos wires) → [ids] o error legible.
107
+ -- Se usa en `/model` sin argumentos y en la ventana de modelo de la web, para no tipear nombres a ciegas.
108
+ export task list_models(cfg)
109
+ require net
110
+ require time
111
+ let headers be {"Authorization": bearer(cfg["key"])}
112
+ when cfg["wire"] == "anthropic"
113
+ set headers to {"x-api-key": cfg["key"], "anthropic-version": ANTHROPIC_VERSION}
114
+ let r be http_get(strip_slash(cfg["base_url"]) + "/models", headers)
115
+ when r["status"] != 200
116
+ give {"models": [], "error": "http " + text(r["status"]) + ": " + slice(text(r["body"]), 0, 200)}
117
+ let doc be json_decode(r["body"])
118
+ let ids be []
119
+ when contains(doc, "data")
120
+ each m in doc["data"]
121
+ when contains(m, "id")
122
+ set ids to append(ids, text(m["id"]))
123
+ give {"models": sort_by(ids, (x) => x), "error": nothing}
124
+
125
+ -- Tokens de entrada servidos desde caché de prompt (cuestan ~10%): DeepSeek `prompt_cache_hit_tokens`,
126
+ -- OpenAI `prompt_tokens_details.cached_tokens`. El presupuesto por turno (loop.syn) los pondera al 10%.
127
+ export task cached_of(u)
128
+ when contains(u, "prompt_cache_hit_tokens")
129
+ give floor(number(u["prompt_cache_hit_tokens"]))
130
+ when contains(u, "prompt_tokens_details")
131
+ when u["prompt_tokens_details"] != nothing and contains(u["prompt_tokens_details"], "cached_tokens")
132
+ give floor(number(u["prompt_tokens_details"]["cached_tokens"]))
133
+ give 0
134
+
135
+ -- costo ponderado de una llamada: cache al 10%, resto al 100%
136
+ export task cost_tokens(u)
137
+ let cached be when contains(u, "cached") then u["cached"] otherwise 0
138
+ give floor((u["input"] - cached) + cached * 0.1 + u["output"])
139
+
140
+ -- Configuración para un proveedor concreto (name "" = el por defecto; model "" = el guardado/preset).
141
+ -- Llamar DENTRO del handler bajo serve.
142
+ export task config_for(name, model)
143
+ require env("LAMPSON_*")
144
+ require secret("LAMPSON_*")
145
+ require file.read(".lampson")
146
+ require file.read(".lampson/*")
147
+ let s be settings.load()
148
+ let default_name be default_provider()
149
+ let n be when name == nothing or name == "" then default_name otherwise lower(name)
150
+ let preset be when contains(PRESETS, n) then PRESETS[n] otherwise PRESETS["openai"]
151
+ let is_env_default be n == env_default()
152
+ -- overrides de .env (WIRE/BASE_URL/MODEL) solo aplican al proveedor de .env
153
+ let wire be when is_env_default then env("LAMPSON_WIRE", preset["wire"]) otherwise preset["wire"]
154
+ let base be when is_env_default then env("LAMPSON_BASE_URL", preset["base_url"]) otherwise preset["base_url"]
155
+ let m be preset["model"]
156
+ when is_env_default
157
+ set m to env("LAMPSON_MODEL", m)
158
+ when n == default_name
159
+ when s["model"] != ""
160
+ set m to s["model"]
161
+ when model != nothing and model != ""
162
+ set m to model
163
+ set m to normalize_model(n, m)
164
+ -- key: guardada en config.json > LAMPSON_API_KEY_<P> > LAMPSON_API_KEY (solo para el proveedor de .env)
165
+ let key be secret("LAMPSON_API_KEY", "ollama")
166
+ when env(key_var(n), "") != ""
167
+ set key to secret(key_var(n), "ollama")
168
+ when settings.raw_key(n) != ""
169
+ set key to as_secret(settings.raw_key(n), "api_key_" + n)
170
+ give {
171
+ "provider": n,
172
+ "wire": wire,
173
+ "base_url": base,
174
+ "model": m,
175
+ -- 8192 (antes 4096): a un modelo razonador 4096 se le pueden ir enteros en pensar y la
176
+ -- respuesta llega vacía (finish_reason=length). Override: LAMPSON_MAX_TOKENS.
177
+ "max_tokens": floor(number(env("LAMPSON_MAX_TOKENS", "8192"))),
178
+ "timeout": number(env("LAMPSON_TIMEOUT", "180")),
179
+ "key": key,
180
+ "has_key": has_key(n)
181
+ }
182
+
183
+ export task config()
184
+ require env("LAMPSON_*")
185
+ require secret("LAMPSON_*")
186
+ require file.read(".lampson")
187
+ require file.read(".lampson/*")
188
+ give config_for("", "")
189
+
190
+ task strip_slash(url)
191
+ when length(url) > 0
192
+ when slice(url, length(url) - 1, length(url)) == "/"
193
+ give slice(url, 0, length(url) - 1)
194
+ give url
195
+
196
+ task err_result(msg)
197
+ give {"text": "", "tool_calls": [], "usage": {"input": 0, "output": 0}, "stop": "error", "error": msg}
198
+
199
+ -- ---------- OpenAI wire (chat/completions) ----------
200
+
201
+ task to_openai_messages(messages)
202
+ let out be []
203
+ each m in messages
204
+ when m["role"] == "assistant"
205
+ let entry be {"role": "assistant", "content": m["content"]}
206
+ when contains(m, "tool_calls")
207
+ when length(m["tool_calls"]) > 0
208
+ let tcs be []
209
+ each tc in m["tool_calls"]
210
+ set tcs to append(tcs, {"id": tc["id"], "type": "function", "function": {"name": tc["name"], "arguments": json_encode(tc["args"])}})
211
+ set entry to {"role": "assistant", "content": m["content"], "tool_calls": tcs}
212
+ set out to append(out, entry)
213
+ otherwise when m["role"] == "tool"
214
+ set out to append(out, {"role": "tool", "tool_call_id": m["tool_call_id"], "content": m["content"]})
215
+ otherwise when has_images(m)
216
+ -- multimodal: partes texto + image_url con data URL (OpenAI, DeepSeek, OpenRouter, Groq…)
217
+ let parts be [{"type": "text", "text": image_handle(m)}]
218
+ each im in m["images"]
219
+ set parts to append(parts, {"type": "image_url", "image_url": {"url": "data:" + im["media_type"] + ";base64," + im["data"]}})
220
+ set out to append(out, {"role": "user", "content": parts})
221
+ otherwise
222
+ set out to append(out, {"role": m["role"], "content": m["content"]})
223
+ give out
224
+
225
+ -- ¿mensaje de usuario con imágenes adjuntas? ({images: [{media_type, data(base64), w?, h?}]} en el formato canónico)
226
+ task has_images(m)
227
+ when m["role"] != "user"
228
+ give false
229
+ when not contains(m, "images")
230
+ give false
231
+ give length(m["images"]) > 0
232
+
233
+ -- ¿El modelo acepta imágenes? Declarado, no adivinado: desconocido = no (así el usuario ve un aviso
234
+ -- claro en vez de un 400). Overrides: "vision": true|false en .lampson/config.json.
235
+ -- Verificado 2026-08-27: MiniMax-M3 (wire anthropic) describe imágenes; deepseek-chat y kimi-k2 no las aceptan.
236
+ export task supports_vision(cfg)
237
+ require file.read(".lampson")
238
+ require file.read(".lampson/*")
239
+ let s be settings.load()
240
+ when contains(s, "vision")
241
+ give s["vision"] == true
242
+ let p be cfg["provider"]
243
+ let m be lower(cfg["model"])
244
+ when p == "anthropic" or p == "minimax"
245
+ give true
246
+ when p == "groq"
247
+ give contains(m, "llama-4") or contains(m, "vision")
248
+ when p == "deepseek"
249
+ give contains(m, "vision")
250
+ when p == "openai"
251
+ give starts_with(m, "gpt-4o") or starts_with(m, "gpt-4.1") or starts_with(m, "gpt-5") or starts_with(m, "o1") or starts_with(m, "o3") or starts_with(m, "o4")
252
+ when p == "openrouter" or p == "ollama"
253
+ give contains(m, "vision") or contains(m, "vl") or contains(m, "gpt-4o") or contains(m, "gpt-5") or contains(m, "claude") or contains(m, "gemini") or contains(m, "llava") or contains(m, "pixtral") or contains(m, "qwen2.5vl") or contains(m, "qwen3-vl")
254
+ when p == "grok"
255
+ give contains(m, "vision") or starts_with(m, "grok-4")
256
+ when p == "kimi"
257
+ give contains(m, "vision") or contains(m, "vl")
258
+ give false
259
+
260
+ -- Modelo sin visión: la imagen se reemplaza por una nota que el modelo puede explicarle al usuario
261
+ -- (patrón de opencode), en vez de reventar en el proveedor. El texto conserva el handle con dimensiones.
262
+ task image_note(m)
263
+ let notes be []
264
+ each e in enumerate(m["images"])
265
+ let im be e["item"]
266
+ let dims be when contains(im, "w") then " " + text(floor(im["w"])) + "x" + text(floor(im["h"])) + "px" otherwise ""
267
+ set notes to append(notes, "[Image " + text(e["index"] + 1) + dims + " attached, but this model does not accept image input — tell the user to switch to a vision model (e.g. anthropic or gpt-4o) if they need it read]")
268
+ give m["content"] + "\n" + join(notes, "\n")
269
+
270
+ -- Handle textual junto a cada imagen (patrón de hermes/deepseek): el modelo sabe cuántas hay y de qué tamaño.
271
+ task image_handle(m)
272
+ let parts be []
273
+ each e in enumerate(m["images"])
274
+ let im be e["item"]
275
+ let dims be when contains(im, "w") then " " + text(floor(im["w"])) + "x" + text(floor(im["h"])) + "px" otherwise ""
276
+ set parts to append(parts, "[Image " + text(e["index"] + 1) + dims + "]")
277
+ give m["content"] + "\n" + join(parts, " ")
278
+
279
+ -- Mensajes listos para el wire: imágenes fuera si el modelo no las acepta
280
+ task prepare(cfg, messages)
281
+ when supports_vision(cfg)
282
+ give messages
283
+ let out be []
284
+ each m in messages
285
+ when has_images(m)
286
+ set out to append(out, {"role": "user", "content": image_note(m)})
287
+ otherwise
288
+ set out to append(out, m)
289
+ give out
290
+
291
+ task to_openai_tools(catalog)
292
+ let out be []
293
+ each t in catalog
294
+ set out to append(out, {"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["parameters"]}})
295
+ give out
296
+
297
+ task parse_args(raw)
298
+ when raw == nothing
299
+ give {}
300
+ when raw == ""
301
+ give {}
302
+ try
303
+ let v be json_decode(raw)
304
+ give v
305
+ recover err
306
+ give {"_raw": raw, "_parse_error": err}
307
+
308
+ task chat_openai(cfg, messages, catalog)
309
+ let body be {"model": cfg["model"], "messages": to_openai_messages(messages), "max_tokens": cfg["max_tokens"]}
310
+ when length(catalog) > 0
311
+ set body to {"model": cfg["model"], "messages": to_openai_messages(messages), "max_tokens": cfg["max_tokens"], "tools": to_openai_tools(catalog)}
312
+ let headers be {"Authorization": bearer(cfg["key"]), "Content-Type": "application/json"}
313
+ let r be http_post(strip_slash(cfg["base_url"]) + "/chat/completions", json_encode(body), headers, cfg["timeout"])
314
+ when contains(r, "error")
315
+ give err_result("network: " + text(error of r))
316
+ when status of r != 200
317
+ give err_result("http " + text(status of r) + ": " + slice(body of r, 0, 800))
318
+ let j be json_decode(body of r)
319
+ let msg be j["choices"][0]["message"]
320
+ let txt be ""
321
+ when contains(msg, "content")
322
+ when msg["content"] != nothing
323
+ set txt to msg["content"]
324
+ let calls be []
325
+ when contains(msg, "tool_calls")
326
+ when msg["tool_calls"] != nothing
327
+ each tc in msg["tool_calls"]
328
+ set calls to append(calls, {"id": tc["id"], "name": tc["function"]["name"], "args": parse_args(tc["function"]["arguments"])})
329
+ let usage be {"input": 0, "output": 0, "cached": 0}
330
+ when contains(j, "usage")
331
+ when j["usage"] != nothing
332
+ set usage to {"input": j["usage"]["prompt_tokens"], "output": j["usage"]["completion_tokens"], "cached": cached_of(j["usage"])}
333
+ -- finish_reason=length: se agotó max_tokens ANTES de terminar. Con un modelo razonador (deepseek-v4-pro)
334
+ -- puede irse TODO el presupuesto en pensar y llegar acá sin texto ni tool calls (visto 2026-08-28:
335
+ -- out=4096 exacto, respuesta vacía, y el turno moría en silencio como "done"). Vacío → error visible;
336
+ -- parcial → marcador para que el usuario sepa que faltó el final.
337
+ let fr be ""
338
+ when contains(j["choices"][0], "finish_reason")
339
+ when j["choices"][0]["finish_reason"] != nothing
340
+ set fr to text(j["choices"][0]["finish_reason"])
341
+ when fr == "length" and length(calls) == 0
342
+ when trim(txt) == ""
343
+ 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í»."}
344
+ set txt to txt + "\n\n[respuesta cortada: se alcanzó max_tokens=" + text(cfg["max_tokens"]) + " — decí «seguí» para que continúe]"
345
+ let finish be when length(calls) > 0 then "tool_calls" otherwise "end"
346
+ give {"text": txt, "tool_calls": calls, "usage": usage, "stop": finish, "error": nothing}
347
+
348
+ -- ---------- Anthropic wire (messages) ----------
349
+
350
+ -- Anthropic exige: system aparte, roles alternados user/assistant, tool results como bloques
351
+ -- tool_result dentro de un mensaje user (varios resultados consecutivos → UN mensaje user).
352
+ task to_anthropic(messages)
353
+ let system be ""
354
+ let out be []
355
+ let pending_results be []
356
+ each m in messages
357
+ when m["role"] == "system"
358
+ set system to system + m["content"]
359
+ otherwise when m["role"] == "tool"
360
+ set pending_results to append(pending_results, {"type": "tool_result", "tool_use_id": m["tool_call_id"], "content": m["content"]})
361
+ otherwise
362
+ when length(pending_results) > 0
363
+ set out to append(out, {"role": "user", "content": pending_results})
364
+ set pending_results to []
365
+ when m["role"] == "assistant"
366
+ let blocks be []
367
+ when m["content"] != ""
368
+ set blocks to append(blocks, {"type": "text", "text": m["content"]})
369
+ when contains(m, "tool_calls")
370
+ each tc in m["tool_calls"]
371
+ set blocks to append(blocks, {"type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc["args"]})
372
+ when length(blocks) == 0
373
+ set blocks to append(blocks, {"type": "text", "text": "(sin contenido)"})
374
+ set out to append(out, {"role": "assistant", "content": blocks})
375
+ otherwise when has_images(m)
376
+ -- bloques image (base64) + texto; si el anterior era user de texto, se funde en bloques (roles alternados)
377
+ let blocks be []
378
+ each im in m["images"]
379
+ set blocks to append(blocks, {"type": "image", "source": {"type": "base64", "media_type": im["media_type"], "data": im["data"]}})
380
+ set blocks to append(blocks, {"type": "text", "text": image_handle(m)})
381
+ let n be length(out)
382
+ let fused be false
383
+ when n > 0
384
+ when out[n - 1]["role"] == "user"
385
+ when text(out[n - 1]["content"]) == out[n - 1]["content"]
386
+ set out to slice(out, 0, n - 1) + [{"role": "user", "content": [{"type": "text", "text": out[n - 1]["content"]}] + blocks}]
387
+ set fused to true
388
+ when not fused
389
+ set out to append(out, {"role": "user", "content": blocks})
390
+ otherwise
391
+ -- dos `user` seguidos (p.ej. el pedido + la salida de un `!comando`) → uno solo
392
+ let n be length(out)
393
+ let merged be false
394
+ when n > 0
395
+ when out[n - 1]["role"] == "user"
396
+ when text(out[n - 1]["content"]) == out[n - 1]["content"]
397
+ set out to slice(out, 0, n - 1) + [{"role": "user", "content": out[n - 1]["content"] + "\n\n" + m["content"]}]
398
+ set merged to true
399
+ when not merged
400
+ set out to append(out, {"role": "user", "content": m["content"]})
401
+ when length(pending_results) > 0
402
+ set out to append(out, {"role": "user", "content": pending_results})
403
+ give {"system": system, "messages": out}
404
+
405
+ task to_anthropic_tools(catalog)
406
+ let out be []
407
+ each t in catalog
408
+ set out to append(out, {"name": t["name"], "description": t["description"], "input_schema": t["parameters"]})
409
+ give out
410
+
411
+ -- Prompt caching Anthropic: es OPT-IN (sin marcadores no cachea NADA). Los mismos 3 breakpoints a los
412
+ -- que convergen opencode (cache-policy.ts) y hermes (prompt_caching.py): el último tool del catálogo,
413
+ -- el system, y el último mensaje user — así cada llamada intra-turno pega en el prefijo (lectura 10%
414
+ -- del precio). OpenAI/DeepSeek cachean solas por prefijo: su wire no lleva marcadores.
415
+ let CACHE_HINT be {"type": "ephemeral"}
416
+
417
+ task mark_last_block(m)
418
+ -- copia del mensaje con cache_control en su último bloque (content texto → un bloque text)
419
+ when text(m["content"]) == m["content"]
420
+ give {"role": m["role"], "content": [{"type": "text", "text": m["content"], "cache_control": CACHE_HINT}]}
421
+ let blocks be m["content"]
422
+ let n be length(blocks)
423
+ let last be {}
424
+ each k in keys(blocks[n - 1])
425
+ set last[k] to blocks[n - 1][k]
426
+ set last["cache_control"] to CACHE_HINT
427
+ give {"role": m["role"], "content": slice(blocks, 0, n - 1) + [last]}
428
+
429
+ task cache_marked_messages(msgs)
430
+ let i be length(msgs) - 1
431
+ while i >= 0
432
+ when msgs[i]["role"] == "user"
433
+ give slice(msgs, 0, i) + [mark_last_block(msgs[i])] + slice(msgs, i + 1, length(msgs))
434
+ set i to i - 1
435
+ give msgs
436
+
437
+ task cache_marked_tools(tools)
438
+ let n be length(tools)
439
+ when n == 0
440
+ give tools
441
+ let last be {}
442
+ each k in keys(tools[n - 1])
443
+ set last[k] to tools[n - 1][k]
444
+ set last["cache_control"] to CACHE_HINT
445
+ give slice(tools, 0, n - 1) + [last]
446
+
447
+ task chat_anthropic(cfg, messages, catalog)
448
+ let conv be to_anthropic(messages)
449
+ let sys be when conv["system"] == "" then "" otherwise [{"type": "text", "text": conv["system"], "cache_control": CACHE_HINT}]
450
+ let msgs be cache_marked_messages(conv["messages"])
451
+ let body be {"model": cfg["model"], "max_tokens": cfg["max_tokens"], "system": sys, "messages": msgs}
452
+ when length(catalog) > 0
453
+ set body to {"model": cfg["model"], "max_tokens": cfg["max_tokens"], "system": sys, "messages": msgs, "tools": cache_marked_tools(to_anthropic_tools(catalog))}
454
+ let headers be {"x-api-key": cfg["key"], "anthropic-version": ANTHROPIC_VERSION, "Content-Type": "application/json"}
455
+ let r be http_post(strip_slash(cfg["base_url"]) + "/messages", json_encode(body), headers, cfg["timeout"])
456
+ when contains(r, "error")
457
+ give err_result("network: " + text(error of r))
458
+ when status of r != 200
459
+ give err_result("http " + text(status of r) + ": " + slice(body of r, 0, 800))
460
+ let j be json_decode(body of r)
461
+ let txt be ""
462
+ let calls be []
463
+ each b in j["content"]
464
+ when b["type"] == "text"
465
+ set txt to txt + b["text"]
466
+ otherwise when b["type"] == "tool_use"
467
+ set calls to append(calls, {"id": b["id"], "name": b["name"], "args": b["input"]})
468
+ let usage be {"input": 0, "output": 0, "cached": 0}
469
+ when contains(j, "usage")
470
+ -- input_tokens de Anthropic EXCLUYE lo cacheado: input = no-cache + escritura de caché + lectura de caché
471
+ -- (la escritura se cobra 1.25x pero se cuenta 1x acá; la lectura pondera 10% en cost_tokens)
472
+ let cr be when contains(j["usage"], "cache_read_input_tokens") then j["usage"]["cache_read_input_tokens"] otherwise 0
473
+ let cw be when contains(j["usage"], "cache_creation_input_tokens") then j["usage"]["cache_creation_input_tokens"] otherwise 0
474
+ set usage to {"input": j["usage"]["input_tokens"] + cr + cw, "output": j["usage"]["output_tokens"], "cached": cr}
475
+ -- stop_reason=max_tokens: mismo tratamiento que finish_reason=length en el wire openai (ver arriba)
476
+ let sr be when contains(j, "stop_reason") then text(j["stop_reason"]) otherwise ""
477
+ when sr == "max_tokens" and length(calls) == 0
478
+ when trim(txt) == ""
479
+ 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. Raise LAMPSON_MAX_TOKENS and say «seguí»."}
480
+ set txt to txt + "\n\n[respuesta cortada: se alcanzó max_tokens=" + text(cfg["max_tokens"]) + " — decí «seguí» para que continúe]"
481
+ let finish be when length(calls) > 0 then "tool_calls" otherwise "end"
482
+ give {"text": txt, "tool_calls": calls, "usage": usage, "stop": finish, "error": nothing}
483
+
484
+ -- ---------- Punto de entrada ----------
485
+
486
+ export task chat(cfg, messages, catalog)
487
+ require net
488
+ require file.read(".lampson")
489
+ require file.read(".lampson/*")
490
+ let ready be prepare(cfg, messages)
491
+ when cfg["wire"] == "anthropic"
492
+ give chat_anthropic(cfg, ready, catalog)
493
+ give chat_openai(cfg, ready, catalog)
494
+
495
+ -- Reintento simple con backoff para 429/5xx/red. Además: "empty response" (el razonador gastó TODO
496
+ -- max_tokens pensando, sin emitir nada) reintenta con el DOBLE de max_tokens (hasta 65536) — así un
497
+ -- LAMPSON_MAX_TOKENS modesto no mata un turno que necesitaba pensar largo.
498
+ export task chat_retry(cfg, messages, catalog, attempts)
499
+ require net
500
+ require time
501
+ let n be 0
502
+ let last be nothing
503
+ let cur be cfg
504
+ while n < attempts
505
+ set n to n + 1
506
+ set last to chat(cur, messages, catalog)
507
+ when last["error"] == nothing
508
+ give last
509
+ let e be last["error"]
510
+ let retryable be starts_with(e, "network") or starts_with(e, "http 429") or starts_with(e, "http 5")
511
+ when starts_with(e, "empty response") and cur["max_tokens"] < 65536
512
+ let bigger be {}
513
+ each k in keys(cur)
514
+ set bigger[k] to cur[k]
515
+ set bigger["max_tokens"] to cur["max_tokens"] * 2
516
+ set cur to bigger
517
+ set retryable to true
518
+ when not retryable
519
+ give last
520
+ when n < attempts
521
+ sleep(n * 2)
522
+ give last
@@ -0,0 +1,111 @@
1
+ -- lib/session.syn — persistencia de sesiones (historial canónico como JSON en .lampson/sessions/)
2
+ --
3
+ -- Aquí: un JSON por sesión, reescrito atómicamente en cada turno (write_file es atómico).
4
+ -- Suficiente para v1 y trivialmente inspeccionable. Migrar a `db("./.lampson/sessions.db")` si crece.
5
+ --
6
+ -- MIGA: no existe append_file → releer + reescribir. write_file crea los directorios padres.
7
+
8
+ use "./tools/memo.syn" as memo
9
+
10
+ let DIR be ".lampson/sessions"
11
+
12
+ export task new_id()
13
+ require time
14
+ give format_time(now(), "%Y%m%d-%H%M%S")
15
+
16
+ task path_of(id)
17
+ give DIR + "/" + id + ".json"
18
+
19
+ export task save(id, messages, meta)
20
+ require file(".lampson")
21
+ require file(".lampson/*")
22
+ require time
23
+ require env("LAMPSON_*")
24
+ -- cada sesión queda marcada con el proyecto (slug del workspace) para que list() no mezcle proyectos
25
+ set meta["project"] to memo.slug()
26
+ -- y con la corrida de lampson que la guardó (blackboard "lampson:run", lo comparte el entry al arrancar):
27
+ -- reanudar una sesión de OTRA corrida dispara la nota de procesos muertos (proc.resume_note)
28
+ observe "lampson:run" as run_info
29
+ when run_info != nothing
30
+ set meta["run"] to run_info["id"]
31
+ let doc be {"id": id, "updated": format_time(now()), "meta": meta, "messages": messages}
32
+ write_file(path_of(id), json_encode(doc))
33
+ give id
34
+
35
+ export task load(id)
36
+ require file.read(".lampson")
37
+ require file.read(".lampson/*")
38
+ let doc be json_decode(read_file(path_of(id)))
39
+ give doc
40
+
41
+ -- ¿el doc cargado fue guardado por OTRA corrida de lampson? (sin marca = sesión vieja → sí)
42
+ export task from_other_run(doc)
43
+ observe "lampson:run" as run_info
44
+ when run_info == nothing
45
+ give false
46
+ when not contains(doc["meta"], "run")
47
+ give true
48
+ give doc["meta"]["run"] != run_info["id"]
49
+
50
+ export task exists(id)
51
+ require file.read(".lampson")
52
+ require file.read(".lampson/*")
53
+ try
54
+ read_file(path_of(id), 1, 1)
55
+ give true
56
+ recover err
57
+ give false
58
+
59
+ -- [{id, updated, title}] del proyecto ACTUAL, ordenado por id descendente (los ids son timestamps).
60
+ -- Sesiones viejas sin marca de proyecto se listan igual (anteriores a esta marca).
61
+ export task list()
62
+ require file.read(".lampson")
63
+ require file.read(".lampson/*")
64
+ require env("LAMPSON_*")
65
+ let mine be memo.slug()
66
+ let out be []
67
+ try
68
+ let entries be list_dir(DIR)
69
+ each e in entries
70
+ when not e["is_dir"]
71
+ let id be replace_text(e["name"], ".json", "")
72
+ try
73
+ let d be load(id)
74
+ let title be when contains(d["meta"], "title") then d["meta"]["title"] otherwise ""
75
+ let project be when contains(d["meta"], "project") then d["meta"]["project"] otherwise mine
76
+ when project == mine
77
+ set out to append(out, {"id": id, "updated": d["updated"], "title": title})
78
+ recover err
79
+ set out to out
80
+ recover err
81
+ give []
82
+ give sort_by(out, (s) => 0 - number(replace_text(replace_text(s["id"], "-", ""), "test", "")))
83
+
84
+ -- Borrar una sesión (no hay delete_file en el runtime: se usa el shell del sistema sobre la ruta fija).
85
+ export task delete(id)
86
+ require exec
87
+ require env("OS")
88
+ require file.read(".lampson")
89
+ require file.read(".lampson/*")
90
+ when not matches(id, "[a-zA-Z0-9_-]{1,40}")
91
+ raise("invalid session id")
92
+ when not exists(id)
93
+ give "no existe " + id
94
+ -- la sesión y su traza (.lampson/trace/<id>.log + .last) se van juntas
95
+ let files be [path_of(id), ".lampson/trace/" + id + ".log", ".lampson/trace/" + id + ".last"]
96
+ each f in files
97
+ when env("OS", "") == "Windows_NT"
98
+ run("cmd", ["/c", "del", "/q", replace_text(f, "/", "\\")], 10)
99
+ otherwise
100
+ run("rm", ["-f", f], 10)
101
+ give "borrada " + id
102
+
103
+ -- Título = primer mensaje de usuario recortado.
104
+ export task title_of(messages)
105
+ each m in messages
106
+ when m["role"] == "user"
107
+ let t be replace_text(m["content"], "\n", " ")
108
+ when length(t) > 60
109
+ give slice(t, 0, 60) + "…"
110
+ give t
111
+ give "(sin título)"