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.
- package/.env.example +29 -0
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/bin/lampson.js +81 -0
- package/chat.syn +799 -0
- package/lamps/example-hello/lamp.json +16 -0
- package/lamps/example-hello/lamp.syn +19 -0
- package/lampson.cmd +4 -0
- package/lampson.ps1 +88 -0
- package/lampson.sh +42 -0
- package/lib/agents.syn +471 -0
- package/lib/git.syn +58 -0
- package/lib/lamps.syn +386 -0
- package/lib/loop.syn +455 -0
- package/lib/lsp.syn +503 -0
- package/lib/mcp.syn +403 -0
- package/lib/permission.syn +154 -0
- package/lib/prompt.syn +75 -0
- package/lib/provider.syn +522 -0
- package/lib/session.syn +111 -0
- package/lib/settings.syn +70 -0
- package/lib/skills.syn +179 -0
- package/lib/tools/bash.syn +105 -0
- package/lib/tools/common.sh +49 -0
- package/lib/tools/common.syn +91 -0
- package/lib/tools/edit.syn +32 -0
- package/lib/tools/find.syn +35 -0
- package/lib/tools/grep.syn +31 -0
- package/lib/tools/img.ps1 +36 -0
- package/lib/tools/img.sh +22 -0
- package/lib/tools/ls.syn +18 -0
- package/lib/tools/memo.syn +148 -0
- package/lib/tools/proc.sh +42 -0
- package/lib/tools/proc.syn +314 -0
- package/lib/tools/process.syn +46 -0
- package/lib/tools/read.syn +25 -0
- package/lib/tools/skill.syn +14 -0
- package/lib/tools/todo.syn +97 -0
- package/lib/tools/write.syn +22 -0
- package/lib/tools.syn +198 -0
- package/lib/trace.syn +116 -0
- package/lib/tree.syn +59 -0
- package/lib/update.syn +57 -0
- package/package.json +40 -0
- package/public/fonts/plex-mono-400-latin-ext.woff2 +0 -0
- package/public/fonts/plex-mono-400-latin.woff2 +0 -0
- package/public/fonts/plex-mono-600-latin-ext.woff2 +0 -0
- package/public/fonts/plex-mono-600-latin.woff2 +0 -0
- package/public/fonts/plex-serif-400-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-400-latin.woff2 +0 -0
- package/public/fonts/plex-serif-400i-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-400i-latin.woff2 +0 -0
- package/public/fonts/plex-serif-600-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-600-latin.woff2 +0 -0
- package/public/index.html +1268 -0
- package/public/vendor/xterm-addon-fit.js +2 -0
- package/public/vendor/xterm.css +218 -0
- package/public/vendor/xterm.js +2 -0
- package/skills/debugging/SKILL.md +33 -0
- package/skills/lampson/SKILL.md +117 -0
- package/skills/synsema/SKILL.md +75 -0
- package/web.syn +468 -0
package/lib/provider.syn
ADDED
|
@@ -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
|
package/lib/session.syn
ADDED
|
@@ -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)"
|