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/settings.syn
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- lib/settings.syn — configuración editable en caliente: proveedor, modelo y API keys
|
|
2
|
+
--
|
|
3
|
+
-- Dónde: lampson/.lampson/config.json (gitignored, local). Lo escriben el onboarding de la terminal, la
|
|
4
|
+
-- ventana "proveedor" de la web y /provider. .env sigue valiendo (LAMPSON_PROVIDER, LAMPSON_API_KEY[_X]):
|
|
5
|
+
-- config.json gana sobre .env, y una key guardada acá se sella con as_secret() al leerla (nunca se
|
|
6
|
+
-- devuelve al navegador ni se imprime; el frontend solo ve has_key).
|
|
7
|
+
--
|
|
8
|
+
-- Forma: {"provider": "deepseek", "model": "deepseek-chat", "keys": {"deepseek": "sk-…"}}
|
|
9
|
+
|
|
10
|
+
let PATH be ".lampson/config.json"
|
|
11
|
+
|
|
12
|
+
export task load()
|
|
13
|
+
require file.read(".lampson")
|
|
14
|
+
require file.read(".lampson/*")
|
|
15
|
+
require env("LAMPSON_*")
|
|
16
|
+
-- LAMPSON_NO_CONFIG=1: ignorar config.json (proveedor/key reales) — lo fija tests/run.ps1|sh para que la
|
|
17
|
+
-- suite hable SOLO con el mock (2026-08-27: agents_test gastó tokens reales de DeepSeek por no tenerlo)
|
|
18
|
+
when env("LAMPSON_NO_CONFIG", "") == "1"
|
|
19
|
+
give {"provider": "", "model": "", "keys": {}}
|
|
20
|
+
try
|
|
21
|
+
let doc be json_decode(read_file(PATH))
|
|
22
|
+
-- claves siempre presentes: quien lee no tiene que chequear (y `and` no cortocircuita)
|
|
23
|
+
when not contains(doc, "keys")
|
|
24
|
+
set doc["keys"] to {}
|
|
25
|
+
when not contains(doc, "provider")
|
|
26
|
+
set doc["provider"] to ""
|
|
27
|
+
when not contains(doc, "model")
|
|
28
|
+
set doc["model"] to ""
|
|
29
|
+
give doc
|
|
30
|
+
recover err
|
|
31
|
+
give {"provider": "", "model": "", "keys": {}}
|
|
32
|
+
|
|
33
|
+
export task save(doc)
|
|
34
|
+
require file(".lampson")
|
|
35
|
+
require file(".lampson/*")
|
|
36
|
+
write_file(PATH, json_encode(doc))
|
|
37
|
+
give true
|
|
38
|
+
|
|
39
|
+
-- key guardada para un proveedor, como TEXTO ("" si no hay). Sellarla con as_secret antes de usarla.
|
|
40
|
+
export task raw_key(name)
|
|
41
|
+
require file.read(".lampson")
|
|
42
|
+
require file.read(".lampson/*")
|
|
43
|
+
let doc be load()
|
|
44
|
+
when contains(doc["keys"], lower(name))
|
|
45
|
+
give text(doc["keys"][lower(name)])
|
|
46
|
+
give ""
|
|
47
|
+
|
|
48
|
+
export task set_key(name, key)
|
|
49
|
+
require file(".lampson")
|
|
50
|
+
require file(".lampson/*")
|
|
51
|
+
let doc be load()
|
|
52
|
+
when trim(key) == ""
|
|
53
|
+
let keys be {}
|
|
54
|
+
each k in keys(doc["keys"])
|
|
55
|
+
when k != lower(name)
|
|
56
|
+
set keys[k] to doc["keys"][k]
|
|
57
|
+
set doc["keys"] to keys
|
|
58
|
+
otherwise
|
|
59
|
+
set doc["keys"][lower(name)] to trim(key)
|
|
60
|
+
save(doc)
|
|
61
|
+
give true
|
|
62
|
+
|
|
63
|
+
export task set_default(name, model)
|
|
64
|
+
require file(".lampson")
|
|
65
|
+
require file(".lampson/*")
|
|
66
|
+
let doc be load()
|
|
67
|
+
set doc["provider"] to lower(name)
|
|
68
|
+
set doc["model"] to model
|
|
69
|
+
save(doc)
|
|
70
|
+
give true
|
package/lib/skills.syn
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
-- lib/skills.syn — skills: procedimientos en Markdown que el modelo carga bajo demanda
|
|
2
|
+
--
|
|
3
|
+
-- Una skill = carpeta con SKILL.md y frontmatter YAML mínimo:
|
|
4
|
+
-- ---
|
|
5
|
+
-- name: deploy
|
|
6
|
+
-- description: Cómo desplegar este proyecto (una línea; es lo que ve el modelo en el índice)
|
|
7
|
+
-- ---
|
|
8
|
+
-- ...instrucciones...
|
|
9
|
+
--
|
|
10
|
+
-- Se buscan en (prioridad de abajo hacia arriba, el nombre repetido gana el más específico):
|
|
11
|
+
-- .lampson/skills-global/<name>/SKILL.md -- ~/.agents/skills (junction que crea lampson.ps1/sh; `npx skills add -g`)
|
|
12
|
+
-- .lampson/skills-claude/<name>/SKILL.md -- ~/.claude/skills (idem; las de Claude Code también sirven acá)
|
|
13
|
+
-- skills/<name>/SKILL.md -- del harness (enseñan a usar lampson y Synsema)
|
|
14
|
+
-- workspace/.claude/skills/<name>/SKILL.md -- del proyecto, instaladas para Claude Code
|
|
15
|
+
-- workspace/.agents/skills/<name>/SKILL.md -- del proyecto, canónico de `npx skills add owner/repo` (skills.sh)
|
|
16
|
+
-- workspace/skills/<name>/SKILL.md -- del proyecto (committeables)
|
|
17
|
+
-- workspace/.lampson/skills/<name>/SKILL.md -- locales al proyecto (no committeadas)
|
|
18
|
+
--
|
|
19
|
+
-- Skills externas (2026-08-27): el estándar "Agent Skills" (agentskills.io) es el mismo formato que usamos
|
|
20
|
+
-- (SKILL.md + frontmatter name/description), así que NO hay instalador propio: `npx skills add owner/repo
|
|
21
|
+
-- --skill x` deja la skill en ./.agents/skills (o ~/.agents/skills con -g) y Lampson la ve en el próximo
|
|
22
|
+
-- arranque. Las carpetas de HOME no se pueden declarar como capability con ruta dinámica → se MONTAN
|
|
23
|
+
-- bajo .lampson/ (mismo truco que workspace/).
|
|
24
|
+
--
|
|
25
|
+
-- El system prompt lleva SOLO el índice (name + description); el contenido entra al contexto
|
|
26
|
+
-- cuando el modelo llama a la tool `skill(name)`. Así el prompt no crece con cada skill.
|
|
27
|
+
|
|
28
|
+
let ROOTS be [
|
|
29
|
+
{"dir": ".lampson/skills-global", "source": "global"},
|
|
30
|
+
{"dir": ".lampson/skills-claude", "source": "global"},
|
|
31
|
+
{"dir": "skills", "source": "harness"},
|
|
32
|
+
{"dir": "workspace/.claude/skills", "source": "project"},
|
|
33
|
+
{"dir": "workspace/.agents/skills", "source": "project"},
|
|
34
|
+
{"dir": "workspace/skills", "source": "project"},
|
|
35
|
+
{"dir": "workspace/.lampson/skills", "source": "local"}
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
task frontmatter(md)
|
|
39
|
+
-- devuelve {name, description, body}; sin frontmatter → name vacío
|
|
40
|
+
let out be {"name": "", "description": "", "body": md}
|
|
41
|
+
when not starts_with(md, "---")
|
|
42
|
+
give out
|
|
43
|
+
let rest be slice(md, 3, length(md))
|
|
44
|
+
let parts be split(rest, "\n---")
|
|
45
|
+
when length(parts) < 2
|
|
46
|
+
give out
|
|
47
|
+
let head be parts[0]
|
|
48
|
+
let body be join(slice(parts, 1, length(parts)), "\n---")
|
|
49
|
+
each line in split(head, "\n")
|
|
50
|
+
let l be trim(line)
|
|
51
|
+
when starts_with(l, "name:")
|
|
52
|
+
set out["name"] to trim(slice(l, 5, length(l)))
|
|
53
|
+
otherwise when starts_with(l, "description:")
|
|
54
|
+
set out["description"] to trim(slice(l, 12, length(l)))
|
|
55
|
+
set out["body"] to trim(body)
|
|
56
|
+
give out
|
|
57
|
+
|
|
58
|
+
task scan_root(root, acc)
|
|
59
|
+
let out be acc
|
|
60
|
+
try
|
|
61
|
+
each e in list_dir(root["dir"])
|
|
62
|
+
when e["is_dir"]
|
|
63
|
+
let path be root["dir"] + "/" + e["name"] + "/SKILL.md"
|
|
64
|
+
try
|
|
65
|
+
let fm be frontmatter(read_file(path))
|
|
66
|
+
let name be when fm["name"] == "" then e["name"] otherwise fm["name"]
|
|
67
|
+
set out[name] to {"name": name, "description": fm["description"], "path": path, "source": root["source"]}
|
|
68
|
+
recover err
|
|
69
|
+
set out to out
|
|
70
|
+
recover err
|
|
71
|
+
give out
|
|
72
|
+
give out
|
|
73
|
+
|
|
74
|
+
-- índice: map name → {name, description, path, source}
|
|
75
|
+
export task index()
|
|
76
|
+
require file.read("skills")
|
|
77
|
+
require file.read("skills/*")
|
|
78
|
+
require file.read("workspace")
|
|
79
|
+
require file.read("workspace/*")
|
|
80
|
+
require file.read(".lampson")
|
|
81
|
+
require file.read(".lampson/*")
|
|
82
|
+
let acc be {}
|
|
83
|
+
each r in ROOTS
|
|
84
|
+
set acc to scan_root(r, acc)
|
|
85
|
+
give acc
|
|
86
|
+
|
|
87
|
+
-- sección del system prompt
|
|
88
|
+
export task prompt_section(idx)
|
|
89
|
+
let names be sort_by(keys(idx), (n) => n)
|
|
90
|
+
when length(names) == 0
|
|
91
|
+
give ""
|
|
92
|
+
let lines be ["", "# Skills (load one with the `skill` tool when relevant — read it BEFORE doing that kind of task)"]
|
|
93
|
+
each n in names
|
|
94
|
+
let s be idx[n]
|
|
95
|
+
set lines to append(lines, "- " + n + " (" + s["source"] + "): " + s["description"])
|
|
96
|
+
give join(lines, "\n")
|
|
97
|
+
|
|
98
|
+
export task load(name)
|
|
99
|
+
require file.read("skills")
|
|
100
|
+
require file.read("skills/*")
|
|
101
|
+
require file.read("workspace")
|
|
102
|
+
require file.read("workspace/*")
|
|
103
|
+
require file.read(".lampson")
|
|
104
|
+
require file.read(".lampson/*")
|
|
105
|
+
let idx be index()
|
|
106
|
+
when not contains(idx, name)
|
|
107
|
+
raise("unknown skill '" + name + "'. Available: " + join(sort_by(keys(idx), (n) => n), ", "))
|
|
108
|
+
let s be idx[name]
|
|
109
|
+
let fm be frontmatter(read_file(s["path"]))
|
|
110
|
+
-- archivos hermanos de la skill (scripts, templates) para que el modelo sepa que existen
|
|
111
|
+
let dir be slice(s["path"], 0, length(s["path"]) - length("/SKILL.md"))
|
|
112
|
+
let extras be []
|
|
113
|
+
each e in list_dir(dir)
|
|
114
|
+
when e["name"] != "SKILL.md"
|
|
115
|
+
set extras to append(extras, dir + "/" + e["name"] + (when e["is_dir"] then "/" otherwise ""))
|
|
116
|
+
let head be "# skill: " + name + " (" + s["source"] + ", " + s["path"] + ")\n"
|
|
117
|
+
when length(extras) > 0
|
|
118
|
+
set head to head + "Files in this skill: " + join(extras, ", ") + "\n"
|
|
119
|
+
give head + "\n" + fm["body"]
|
|
120
|
+
|
|
121
|
+
-- ---------- instalación de skills externas (skills.sh / cualquier repo con SKILL.md) ----------
|
|
122
|
+
-- No reinventamos el instalador: `npx skills add <owner/repo> --skill <name>` (vercel-labs/skills) escribe
|
|
123
|
+
-- en ./.agents/skills (scope project) o ~/.agents/skills (-g, scope global — sirve para TODOS los proyectos,
|
|
124
|
+
-- que es lo que uno quiere con una skill de Go, de Next, de diseño…). `-a codex -y` = no interactivo
|
|
125
|
+
-- (codex ya usa la carpeta canónica, así que no crea symlinks extra; verificado 2026-08-27). Es una acción
|
|
126
|
+
-- con efectos fuera del workspace → permission.syn la marca "ask" SIEMPRE (human in the loop), incluso en yolo.
|
|
127
|
+
task shell_exe()
|
|
128
|
+
require env("LAMPSON_*")
|
|
129
|
+
require env("OS")
|
|
130
|
+
let is_win be env("OS", "") == "Windows_NT"
|
|
131
|
+
give env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
|
|
132
|
+
|
|
133
|
+
task valid_slug(s)
|
|
134
|
+
when s == nothing or s == ""
|
|
135
|
+
give false
|
|
136
|
+
give matches(s, "[a-zA-Z0-9._/@:-]{1,120}")
|
|
137
|
+
|
|
138
|
+
-- la junction/symlink .lampson/skills-global → ~/.agents/skills la crea lampson.ps1/sh al arrancar, pero si
|
|
139
|
+
-- la carpeta HOME no existía todavía (primera instalación global) hay que crear ambas ahora
|
|
140
|
+
task ensure_global_mount()
|
|
141
|
+
require exec
|
|
142
|
+
require env("LAMPSON_*")
|
|
143
|
+
require env("OS")
|
|
144
|
+
let is_win be env("OS", "") == "Windows_NT"
|
|
145
|
+
let win be "src=\"$USERPROFILE/.agents/skills\"; mkdir -p \"$src\"; [ -e .lampson/skills-global ] || cmd //c mklink //J \".lampson\\\\skills-global\" \"$(cygpath -w \"$src\")\" >/dev/null"
|
|
146
|
+
let unix be "src=\"$HOME/.agents/skills\"; mkdir -p \"$src\"; [ -e .lampson/skills-global ] || ln -s \"$src\" .lampson/skills-global"
|
|
147
|
+
run(shell_exe(), ["-c", when is_win then win otherwise unix], 30, {"cwd": "."})
|
|
148
|
+
|
|
149
|
+
task tail_out(s)
|
|
150
|
+
let t be strip_ansi(replace_text(s, "\r", ""))
|
|
151
|
+
when length(t) > 3000
|
|
152
|
+
give slice(t, length(t) - 3000, length(t))
|
|
153
|
+
give t
|
|
154
|
+
|
|
155
|
+
export task install(source, name, scope)
|
|
156
|
+
require exec
|
|
157
|
+
require time
|
|
158
|
+
require env("LAMPSON_*")
|
|
159
|
+
require env("OS")
|
|
160
|
+
require file.read("skills")
|
|
161
|
+
require file.read("skills/*")
|
|
162
|
+
require file.read("workspace")
|
|
163
|
+
require file.read("workspace/*")
|
|
164
|
+
require file.read(".lampson")
|
|
165
|
+
require file.read(".lampson/*")
|
|
166
|
+
when not valid_slug(source)
|
|
167
|
+
raise("invalid source '" + text(source) + "' (expected owner/repo or a github URL)")
|
|
168
|
+
when not valid_slug(name)
|
|
169
|
+
raise("invalid skill name '" + text(name) + "'")
|
|
170
|
+
let global be scope != "project"
|
|
171
|
+
let argv be ["-y", "skills", "add", source, "--skill", name, "-a", "codex", "-y"]
|
|
172
|
+
when global
|
|
173
|
+
set argv to append(argv, "-g")
|
|
174
|
+
ensure_global_mount()
|
|
175
|
+
let r be run(shell_exe(), ["-c", "CI=1 npx " + join(argv, " ") + " 2>&1"], 240, {"cwd": when global then "." otherwise "workspace"})
|
|
176
|
+
let idx be index()
|
|
177
|
+
when contains(idx, name)
|
|
178
|
+
give "installed skill '" + name + "' (" + (when global then "global: ~/.agents/skills, available in every project" otherwise "project: ./.agents/skills") + "). It is in the skills index now — call skill(name=\"" + name + "\") to load it.\n" + tail_out(r["stdout"])
|
|
179
|
+
give "npx skills finished (exit " + text(r["exit_code"]) + ") but '" + name + "' is not in the skills index. Output:\n" + tail_out(r["stdout"])
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
-- lib/tools/bash.syn — ejecutar un comando de shell con cwd = workspace/
|
|
2
|
+
--
|
|
3
|
+
-- MIGAS:
|
|
4
|
+
-- * en Windows `bash` a secas resuelve al de WSL y cuelga; usar Git Bash explícito (default) o cmd.
|
|
5
|
+
-- * NO usar `run()` para el comando del modelo: se cuelga para siempre si el comando deja un
|
|
6
|
+
-- descendiente vivo (Windows hereda el pipe de stdout) y su timeout mata solo al hijo directo.
|
|
7
|
+
-- Desde 2026-08-27 el comando corre con `proc_spawn` (v0.6.9): eventos por línea hasta el exit o el
|
|
8
|
+
-- plazo, stdin cerrado (EOF: nada se queda esperando teclado), y `proc_close` mata el ÁRBOL
|
|
9
|
+
-- (Job Object / process group) — un nieto con el pipe abierto no cuelga (1 s de gracia). Adiós
|
|
10
|
+
-- bash_wrapper.sh y archivos temporales.
|
|
11
|
+
-- * el proceso hijo NO hereda el scope de Synsema (un `cat ../x` en bash funciona). El aislamiento del
|
|
12
|
+
-- shell lo dan permission.syn (patrones) + el operador (contenedor si hace falta).
|
|
13
|
+
use "./common.syn" as c
|
|
14
|
+
use "./proc.syn" as proc
|
|
15
|
+
|
|
16
|
+
export task shell_config()
|
|
17
|
+
require env("LAMPSON_*")
|
|
18
|
+
require env("OS")
|
|
19
|
+
let is_win be env("OS", "") == "Windows_NT"
|
|
20
|
+
let shell be env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
|
|
21
|
+
let is_cmd be c.ends_with(lower(shell), "cmd.exe") or lower(shell) == "cmd"
|
|
22
|
+
give {"shell": shell, "flag": env("LAMPSON_SHELL_FLAG", when is_cmd then "/c" otherwise "-c"), "is_cmd": is_cmd}
|
|
23
|
+
|
|
24
|
+
-- comandos que son servidores/watchers: rechazarlos ANTES de correrlos. El timeout mata el árbol, pero
|
|
25
|
+
-- un servidor lanzado acá dejó un huérfano con el puerto 3000 tomado y el `process` real se fue al 3001
|
|
26
|
+
-- (2026-08-27). La descripción ya lo decía; el modelo lo intentó igual "con | head" — hay que impedirlo.
|
|
27
|
+
let SERVER_RE be ".*((npm|pnpm|yarn|bun)( run)? (dev|start|serve|watch|preview)|npx (next|vite|nodemon|serve|http-server)|next (dev|start)|vite( |$)|nodemon|synsema serve|uvicorn|gunicorn|flask run|manage\\.py runserver|python3? -m http\\.server|rails s(erver)?|php -S|ng serve|cargo (run|watch)|go run|dotnet (run|watch)|tail -f).*"
|
|
28
|
+
|
|
29
|
+
export task looks_like_server(command)
|
|
30
|
+
let cmd be trim(lower(replace_text(command, "\n", " ")))
|
|
31
|
+
when matches(cmd, "timeout [0-9]+.*")
|
|
32
|
+
give false
|
|
33
|
+
give matches(cmd, SERVER_RE)
|
|
34
|
+
|
|
35
|
+
-- corre argv con plazo real: junta stdout+stderr por líneas, corta al plazo y mata el árbol
|
|
36
|
+
export task run_capture(exe, argv, t)
|
|
37
|
+
require exec
|
|
38
|
+
require time
|
|
39
|
+
require file("workspace")
|
|
40
|
+
require file("workspace/*")
|
|
41
|
+
let p be proc_spawn(exe, argv, {"cwd": c.ROOT, "stderr": "merge", "on_full": "drop_oldest"})
|
|
42
|
+
proc_close_stdin(p)
|
|
43
|
+
let deadline be now() + t
|
|
44
|
+
let lines be []
|
|
45
|
+
let size be 0
|
|
46
|
+
let cut be false
|
|
47
|
+
let code be nothing
|
|
48
|
+
let timed_out be false
|
|
49
|
+
while code == nothing and not timed_out
|
|
50
|
+
let left be deadline - now()
|
|
51
|
+
when left <= 0
|
|
52
|
+
set timed_out to true
|
|
53
|
+
otherwise
|
|
54
|
+
let ev be proc_recv(p, when left < 0.2 then 0.2 otherwise left)
|
|
55
|
+
when ev == nothing
|
|
56
|
+
set timed_out to now() >= deadline
|
|
57
|
+
otherwise when ev["type"] == "exit"
|
|
58
|
+
set code to ev["data"]["exit_code"]
|
|
59
|
+
otherwise when size < 1000000
|
|
60
|
+
set lines to append(lines, ev["data"])
|
|
61
|
+
set size to size + length(ev["data"]) + 1
|
|
62
|
+
otherwise
|
|
63
|
+
set cut to true
|
|
64
|
+
proc_close(p)
|
|
65
|
+
let out be join(lines, "\n")
|
|
66
|
+
when cut
|
|
67
|
+
set out to out + "\n[output truncated at 1 MB]"
|
|
68
|
+
when timed_out
|
|
69
|
+
give "ERROR: timeout after " + text(t) + "s — the command and its child processes were killed. For servers/watchers use the process tool. Output so far:\n" + out
|
|
70
|
+
when code != 0
|
|
71
|
+
set out to out + "\n[exit code " + text(code) + "]"
|
|
72
|
+
give out
|
|
73
|
+
|
|
74
|
+
export task tool(command, timeout)
|
|
75
|
+
require exec
|
|
76
|
+
require time
|
|
77
|
+
require env("LAMPSON_*")
|
|
78
|
+
require env("OS")
|
|
79
|
+
require file(".lampson")
|
|
80
|
+
require file(".lampson/*")
|
|
81
|
+
when looks_like_server(command)
|
|
82
|
+
give "REFUSED: this looks like a server/watcher (`" + command + "`). bash kills its process tree at the timeout, and a dev server left half-killed keeps its port busy. Use the process tool instead: process(action=\"start\", name=\"dev\", command=\"...\"), then process(action=\"logs\") or just keep working — its new output is appended to every bash result. If it is really a one-shot command, wrap it with `timeout 30 ...` and explain why."
|
|
83
|
+
let sc be shell_config()
|
|
84
|
+
let t be when timeout == nothing then 120 otherwise floor(timeout)
|
|
85
|
+
when t > 600
|
|
86
|
+
set t to 600
|
|
87
|
+
when t < 1
|
|
88
|
+
set t to 1
|
|
89
|
+
let out be run_capture(sc["shell"], [sc["flag"], command], t)
|
|
90
|
+
when trim(out) == ""
|
|
91
|
+
set out to "(no output)"
|
|
92
|
+
-- lo que pasó mientras tanto en los procesos gestionados (servidores) — el agente "ve la consola"
|
|
93
|
+
let rep be proc.report()
|
|
94
|
+
when rep != ""
|
|
95
|
+
set out to out + "\n\n" + rep
|
|
96
|
+
give c.truncate(out, c.MAX_OUTPUT)
|
|
97
|
+
|
|
98
|
+
export let SPEC be {
|
|
99
|
+
"name": "bash",
|
|
100
|
+
"description": "Run a shell command with the workspace root as working directory (no persistent shell state between calls: cwd and env reset). Returns stdout+stderr and [exit code N] when non-zero. Use it for git, tests, builds, package managers. Commands are killed (with their child processes) after the timeout (default 120s, max 600) — so NEVER run a server, watcher or REPL here: use the `process` tool (start/logs/stop) for anything long-running; its new log lines are appended to every bash result automatically. Avoid interactive commands. Prefer read/grep/find over cat/grep/find for files.",
|
|
101
|
+
"parameters": {"type": "object", "properties": {
|
|
102
|
+
"command": {"type": "string"},
|
|
103
|
+
"timeout": {"type": "integer", "description": "Seconds (optional, default 120, max 600)"}
|
|
104
|
+
}, "required": ["command"]}
|
|
105
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# common.sh — helpers de shell de proc.sh (kill_tree para pids AJENOS; Windows/Git Bash, Linux, macOS)
|
|
2
|
+
#
|
|
3
|
+
# Windows (MSYS/Git Bash): hay DOS árboles. El de MSYS (bash → bash) no lo ve taskkill /T, así que se
|
|
4
|
+
# recorre PPID→PID con `ps`; y cada bash puede tener descendientes NATIVOS (npm.cmd → cmd → node → node)
|
|
5
|
+
# que MSYS no ve pero Windows sí: por eso a cada WINPID se le hace `taskkill /T /F` (árbol nativo).
|
|
6
|
+
# Sin el /T, un `npm run dev` lanzado desde bash sobrevivía al timeout con el puerto tomado (2026-08-27).
|
|
7
|
+
# Linux/macOS: `pgrep -P` para descender y kill -9. (Probado en Windows; unix escrito con cuidado, sin probar.)
|
|
8
|
+
is_win() { case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; *) return 1 ;; esac; }
|
|
9
|
+
|
|
10
|
+
kill_tree() {
|
|
11
|
+
local root="$1" k
|
|
12
|
+
if is_win; then
|
|
13
|
+
for k in $(ps 2>/dev/null | awk -v p="$root" 'NR>1 && $2==p {print $1}'); do kill_tree "$k"; done
|
|
14
|
+
local w; w="$(ps 2>/dev/null | awk -v p="$root" 'NR>1 && $1==p {print $4}')"
|
|
15
|
+
if [ -n "$w" ] && command -v taskkill >/dev/null 2>&1; then taskkill //T //F //PID "$w" >/dev/null 2>&1; fi
|
|
16
|
+
kill -9 "$root" 2>/dev/null
|
|
17
|
+
else
|
|
18
|
+
for k in $(pgrep -P "$root" 2>/dev/null); do kill_tree "$k"; done
|
|
19
|
+
kill -9 "$root" 2>/dev/null
|
|
20
|
+
fi
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# ¿el PID sigue siendo NUESTRO proceso (y no otro que reutilizó el número tras un reinicio)?
|
|
24
|
+
# Windows: el WINPID guardado en el pidfile debe coincidir con el actual. (Antes se exigía que el
|
|
25
|
+
# COMMAND fuera "bash": falso negativo, porque `bash -c "a && npm run dev"` hace exec del último comando
|
|
26
|
+
# y ps pasa a mostrar cmd/node → el process tool declaraba "EXITED" a un servidor vivo, 2026-08-27.)
|
|
27
|
+
# Unix: comparamos el instante de arranque guardado en el pidfile.
|
|
28
|
+
pid_alive() {
|
|
29
|
+
local pid="$1" stamp="$2"
|
|
30
|
+
[ -n "$pid" ] || return 1
|
|
31
|
+
kill -0 "$pid" 2>/dev/null || return 1
|
|
32
|
+
if is_win; then
|
|
33
|
+
local w; w="$(ps -p "$pid" 2>/dev/null | awk 'NR>1 {print $4; exit}')"
|
|
34
|
+
[ -n "$w" ] || return 1
|
|
35
|
+
if [ -n "$stamp" ]; then [ "$w" = "$stamp" ] || return 1; fi
|
|
36
|
+
else
|
|
37
|
+
if [ -n "$stamp" ]; then
|
|
38
|
+
local now; now="$(ps -o lstart= -p "$pid" 2>/dev/null | tr -s ' ')"
|
|
39
|
+
[ "$now" = "$stamp" ] || return 1
|
|
40
|
+
fi
|
|
41
|
+
fi
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
proc_stamp() {
|
|
46
|
+
if is_win; then ps -p "$1" 2>/dev/null | awk 'NR>1 {print $4; exit}'; else ps -o lstart= -p "$1" 2>/dev/null | tr -s ' '; fi
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
abs() { case "$1" in /*|?:*) printf '%s' "$1" ;; *) printf '%s/%s' "$PWD" "$1" ;; esac; }
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- lib/tools/common.syn — helpers compartidos por las tools (puros, sin capacidades)
|
|
2
|
+
--
|
|
3
|
+
-- MODELO DE WORKSPACE (miga de seguridad, verificada en v0.6.7):
|
|
4
|
+
-- el scope `file("./*")` se comporta como `"*"` (disco entero) — un bug del runtime; `file("dir/*")`
|
|
5
|
+
-- sí deniega `..`, absolutos y hermanos. Por eso el proyecto objetivo se MONTA como `./workspace`
|
|
6
|
+
-- (junction en Windows / symlink en unix, lo hace lampson.ps1 / lampson.sh) y TODAS las tools
|
|
7
|
+
-- declaran `file("workspace/*")`. El modelo ve paths relativos a la raíz del workspace; `ws()` los
|
|
8
|
+
-- traduce. Es el mismo modelo mental que `docker -v proyecto:/workspace`.
|
|
9
|
+
|
|
10
|
+
export let ROOT be "workspace"
|
|
11
|
+
export let MAX_OUTPUT be 30000
|
|
12
|
+
export let IGNORED_DIRS be [".git", "node_modules", ".synsema", ".lampson", "workspace", "target", "dist", "build", "coverage", "__pycache__", ".venv", ".next", ".netlify", ".cache", ".turbo"]
|
|
13
|
+
|
|
14
|
+
-- path del modelo → path real bajo workspace/
|
|
15
|
+
export task ws(path)
|
|
16
|
+
let p be when path == nothing then "." otherwise replace_text(text(path), "\\", "/")
|
|
17
|
+
let stripping be true
|
|
18
|
+
while stripping
|
|
19
|
+
when starts_with(p, "./")
|
|
20
|
+
set p to slice(p, 2, length(p))
|
|
21
|
+
otherwise when starts_with(p, "/")
|
|
22
|
+
set p to slice(p, 1, length(p))
|
|
23
|
+
otherwise
|
|
24
|
+
set stripping to false
|
|
25
|
+
when p == "." or p == "" or p == ROOT
|
|
26
|
+
give ROOT
|
|
27
|
+
when starts_with(p, ROOT + "/")
|
|
28
|
+
give p
|
|
29
|
+
-- los resultados largos van a .lampson/spill/<call>.txt (loop.spill) y el modelo los relee con read/grep:
|
|
30
|
+
-- viven en la raíz de lampson, no en el workspace (2026-08-28: "File not found: workspace/.lampson/spill/…")
|
|
31
|
+
when starts_with(p, ".lampson/spill/") and not contains(p, "..")
|
|
32
|
+
give p
|
|
33
|
+
give ROOT + "/" + p
|
|
34
|
+
|
|
35
|
+
-- path real → path como lo ve el modelo
|
|
36
|
+
export task unws(path)
|
|
37
|
+
when starts_with(path, ROOT + "/")
|
|
38
|
+
give slice(path, length(ROOT) + 1, length(path))
|
|
39
|
+
when path == ROOT
|
|
40
|
+
give "."
|
|
41
|
+
give path
|
|
42
|
+
|
|
43
|
+
export task truncate(s, max)
|
|
44
|
+
when length(s) > max
|
|
45
|
+
give slice(s, 0, max) + `\n\n[... truncated: {text(length(s) - max)} more chars ...]`
|
|
46
|
+
give s
|
|
47
|
+
|
|
48
|
+
export task ends_with(s, suffix)
|
|
49
|
+
when length(suffix) > length(s)
|
|
50
|
+
give false
|
|
51
|
+
give slice(s, length(s) - length(suffix), length(s)) == suffix
|
|
52
|
+
|
|
53
|
+
-- glob mínimo: "*.ts" (sufijo), "test_*" (prefijo), "*foo*" (contiene), exacto.
|
|
54
|
+
export task glob_match(name, pat)
|
|
55
|
+
when pat == "*"
|
|
56
|
+
give true
|
|
57
|
+
when pat == ""
|
|
58
|
+
give true
|
|
59
|
+
let star_first be starts_with(pat, "*")
|
|
60
|
+
let star_last be ends_with(pat, "*")
|
|
61
|
+
when star_first and star_last
|
|
62
|
+
give contains(name, slice(pat, 1, length(pat) - 1))
|
|
63
|
+
when star_first
|
|
64
|
+
give ends_with(name, slice(pat, 1, length(pat)))
|
|
65
|
+
when star_last
|
|
66
|
+
give starts_with(name, slice(pat, 0, length(pat) - 1))
|
|
67
|
+
give name == pat
|
|
68
|
+
|
|
69
|
+
-- ---------- leer antes de editar (política de observación, tomada de deepseek-harness fs-observation-policy) ----------
|
|
70
|
+
-- `read` registra en el blackboard el hash del archivo que el modelo vio ("observed:<path>"); `edit` y `write`
|
|
71
|
+
-- sobre un archivo existente exigen esa observación y que el archivo no haya cambiado desde entonces (CAS por
|
|
72
|
+
-- hash). Es un gate en CÓDIGO, no una regla de prompt: elimina "edita a ciegas y pisa" sin gastar tokens.
|
|
73
|
+
export task file_hash(real)
|
|
74
|
+
require file.read("workspace")
|
|
75
|
+
require file.read("workspace/*")
|
|
76
|
+
give decode(sha256(read_file(real)), "hex")
|
|
77
|
+
|
|
78
|
+
export task mark_observed(real)
|
|
79
|
+
require file.read("workspace")
|
|
80
|
+
require file.read("workspace/*")
|
|
81
|
+
share file_hash(real) as "observed:" + real
|
|
82
|
+
|
|
83
|
+
export task check_observed(real, action)
|
|
84
|
+
require file.read("workspace")
|
|
85
|
+
require file.read("workspace/*")
|
|
86
|
+
observe "observed:" + real as seen
|
|
87
|
+
let shown be unws(real)
|
|
88
|
+
when seen == nothing
|
|
89
|
+
raise(action + " requires reading \"" + shown + "\" first: call read on it (the whole file, or the region you will change), then " + action)
|
|
90
|
+
when seen != file_hash(real)
|
|
91
|
+
raise("\"" + shown + "\" changed on disk since you read it (another tool, a build, or the user): read it again, then " + action)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- lib/tools/edit.syn — reemplazo exacto y único (sin fuzzy replacers: exactitud antes que magia)
|
|
2
|
+
use "./common.syn" as c
|
|
3
|
+
|
|
4
|
+
export task tool(path, old_string, new_string, replace_all)
|
|
5
|
+
require file("workspace")
|
|
6
|
+
require file("workspace/*")
|
|
7
|
+
let real be c.ws(path)
|
|
8
|
+
let shown be c.unws(real)
|
|
9
|
+
c.check_observed(real, "edit")
|
|
10
|
+
let content be read_file(real)
|
|
11
|
+
let parts be split(content, old_string)
|
|
12
|
+
let n be length(parts) - 1
|
|
13
|
+
when n == 0
|
|
14
|
+
raise("old_string not found in " + shown + " — read the file and copy the exact text (whitespace included)")
|
|
15
|
+
let all be when replace_all == nothing then false otherwise replace_all
|
|
16
|
+
when n > 1
|
|
17
|
+
when not all
|
|
18
|
+
raise(`old_string appears {text(n)} times in {shown}; include more surrounding context to make it unique, or pass replace_all=true`)
|
|
19
|
+
write_file(real, join(parts, new_string))
|
|
20
|
+
c.mark_observed(real)
|
|
21
|
+
give `edited {shown}: {text(n)} replacement(s)`
|
|
22
|
+
|
|
23
|
+
export let SPEC be {
|
|
24
|
+
"name": "edit",
|
|
25
|
+
"description": "Replace an exact string in a file. old_string must match EXACTLY (including whitespace and indentation) and be unique in the file, otherwise the call fails — include more surrounding lines to disambiguate, or set replace_all=true to replace every occurrence. The file MUST have been read in this session (and not changed since), or the call is rejected.",
|
|
26
|
+
"parameters": {"type": "object", "properties": {
|
|
27
|
+
"path": {"type": "string"},
|
|
28
|
+
"old_string": {"type": "string"},
|
|
29
|
+
"new_string": {"type": "string"},
|
|
30
|
+
"replace_all": {"type": "boolean"}
|
|
31
|
+
}, "required": ["path", "old_string", "new_string"]}
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- lib/tools/find.syn — buscar archivos por nombre (walk recursivo con glob mínimo)
|
|
2
|
+
use "./common.syn" as c
|
|
3
|
+
|
|
4
|
+
task walk(dir, pat, acc, depth)
|
|
5
|
+
when depth > 8
|
|
6
|
+
give acc
|
|
7
|
+
when length(acc) > 500
|
|
8
|
+
give acc
|
|
9
|
+
let out be acc
|
|
10
|
+
each e in list_dir(dir)
|
|
11
|
+
let full be dir + "/" + e["name"]
|
|
12
|
+
when e["is_dir"]
|
|
13
|
+
when not contains(c.IGNORED_DIRS, e["name"])
|
|
14
|
+
set out to walk(full, pat, out, depth + 1)
|
|
15
|
+
otherwise
|
|
16
|
+
when c.glob_match(e["name"], pat)
|
|
17
|
+
set out to append(out, c.unws(full))
|
|
18
|
+
give out
|
|
19
|
+
|
|
20
|
+
export task tool(pattern, path)
|
|
21
|
+
require file.read("workspace")
|
|
22
|
+
require file.read("workspace/*")
|
|
23
|
+
let found be walk(c.ws(path), pattern, [], 0)
|
|
24
|
+
when length(found) == 0
|
|
25
|
+
give "no files matched " + pattern
|
|
26
|
+
give join(found, "\n")
|
|
27
|
+
|
|
28
|
+
export let SPEC be {
|
|
29
|
+
"name": "find",
|
|
30
|
+
"description": "Find files by name pattern, recursively (skips .git, node_modules, build dirs). Pattern is a simple glob: '*.ts', 'test_*', '*config*', or an exact name. Returns paths relative to the workspace root, max 500.",
|
|
31
|
+
"parameters": {"type": "object", "properties": {
|
|
32
|
+
"pattern": {"type": "string"},
|
|
33
|
+
"path": {"type": "string", "description": "Directory to start from (default: workspace root)"}
|
|
34
|
+
}, "required": ["pattern"]}
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- lib/tools/grep.syn — buscar contenido (builtin grep: streaming por línea, literal o regex)
|
|
2
|
+
use "./common.syn" as c
|
|
3
|
+
|
|
4
|
+
export task tool(pattern, path, glob, regex)
|
|
5
|
+
require file.read("workspace")
|
|
6
|
+
require file.read("workspace/*")
|
|
7
|
+
require file.read(".lampson")
|
|
8
|
+
require file.read(".lampson/*")
|
|
9
|
+
let rx be when regex == nothing then true otherwise regex
|
|
10
|
+
let opts be when glob == nothing then {"regex": rx, "max_results": 200} otherwise {"regex": rx, "max_results": 200, "glob": glob}
|
|
11
|
+
let r be grep(c.ws(path), pattern, opts)
|
|
12
|
+
let lines be []
|
|
13
|
+
each m in r["matches"]
|
|
14
|
+
set lines to append(lines, c.unws(replace_text(m["file"], "\\", "/")) + ":" + text(m["line"]) + ": " + trim(m["text"]))
|
|
15
|
+
when length(lines) == 0
|
|
16
|
+
give "no matches"
|
|
17
|
+
let out be join(lines, "\n")
|
|
18
|
+
when r["truncated"]
|
|
19
|
+
set out to out + "\n[truncated at 200 results]"
|
|
20
|
+
give c.truncate(out, c.MAX_OUTPUT)
|
|
21
|
+
|
|
22
|
+
export let SPEC be {
|
|
23
|
+
"name": "grep",
|
|
24
|
+
"description": "Search file contents (regex by default; set regex=false for a literal). Returns 'file:line: text' lines, max 200. Use glob to filter filenames (e.g. '*.syn').",
|
|
25
|
+
"parameters": {"type": "object", "properties": {
|
|
26
|
+
"pattern": {"type": "string"},
|
|
27
|
+
"path": {"type": "string", "description": "File or directory (default: workspace root)"},
|
|
28
|
+
"glob": {"type": "string"},
|
|
29
|
+
"regex": {"type": "boolean"}
|
|
30
|
+
}, "required": ["pattern"]}
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# img.ps1 — imágenes para el REPL (Windows). Imprime UNA línea: media_type|ancho|alto|base64
|
|
2
|
+
# powershell -File lib/tools/img.ps1 clip # imagen del portapapeles (PNG)
|
|
3
|
+
# powershell -File lib/tools/img.ps1 file <ruta> # archivo png/jpg/gif/webp
|
|
4
|
+
# Reduce el lado mayor a 1568 px (lo que un modelo con visión aprovecha) y saca PNG, o JPEG si queda pesado.
|
|
5
|
+
param([string]$Mode, [string]$Path)
|
|
6
|
+
$ErrorActionPreference = "Stop"
|
|
7
|
+
Add-Type -AssemblyName System.Drawing
|
|
8
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
9
|
+
|
|
10
|
+
if ($Mode -eq "clip") {
|
|
11
|
+
$img = [System.Windows.Forms.Clipboard]::GetImage()
|
|
12
|
+
if ($null -eq $img) { Write-Output "ERROR|no hay una imagen en el portapapeles (copiá una captura o una imagen y repetí /paste)"; exit 0 }
|
|
13
|
+
} else {
|
|
14
|
+
if (-not (Test-Path -LiteralPath $Path)) { Write-Output "ERROR|no existe $Path"; exit 0 }
|
|
15
|
+
$img = [System.Drawing.Image]::FromFile((Resolve-Path -LiteralPath $Path).Path)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
$max = 1568
|
|
19
|
+
$scale = [Math]::Min(1.0, $max / [Math]::Max($img.Width, $img.Height))
|
|
20
|
+
$w = [int][Math]::Round($img.Width * $scale); $h = [int][Math]::Round($img.Height * $scale)
|
|
21
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $h
|
|
22
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
23
|
+
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
24
|
+
$g.DrawImage($img, 0, 0, $w, $h); $g.Dispose()
|
|
25
|
+
|
|
26
|
+
$ms = New-Object IO.MemoryStream
|
|
27
|
+
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
28
|
+
$type = "image/png"
|
|
29
|
+
if ($ms.Length -gt 1400000) {
|
|
30
|
+
$ms = New-Object IO.MemoryStream
|
|
31
|
+
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" }
|
|
32
|
+
$params = New-Object System.Drawing.Imaging.EncoderParameters 1
|
|
33
|
+
$params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality), 85L
|
|
34
|
+
$bmp.Save($ms, $codec, $params); $type = "image/jpeg"
|
|
35
|
+
}
|
|
36
|
+
Write-Output ("{0}|{1}|{2}|{3}" -f $type, $w, $h, [Convert]::ToBase64String($ms.ToArray()))
|