lampson 0.1.3 → 0.2.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 (55) hide show
  1. package/.env.example +9 -0
  2. package/README.md +87 -4
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +133 -0
  5. package/cli.syn +68 -0
  6. package/hub.tpl.syn +127 -0
  7. package/lampson.ps1 +77 -51
  8. package/lampson.sh +68 -22
  9. package/lib/agents.syn +1 -1
  10. package/lib/approvals.syn +179 -0
  11. package/lib/lsp.syn +10 -1
  12. package/lib/mcp.syn +10 -1
  13. package/lib/permission.syn +18 -0
  14. package/lib/sched_run.syn +161 -0
  15. package/lib/schedule.syn +660 -0
  16. package/lib/session.syn +38 -1
  17. package/lib/settings.syn +66 -3
  18. package/lib/skills.syn +2 -0
  19. package/lib/tools.syn +94 -2
  20. package/lib/workspaces.syn +555 -0
  21. package/package.json +3 -1
  22. package/public/css/chat.css +56 -0
  23. package/public/css/hub.css +12 -0
  24. package/public/css/layout.css +97 -0
  25. package/public/css/panel.css +125 -0
  26. package/public/css/sidebar.css +80 -0
  27. package/public/css/tokens.css +57 -0
  28. package/public/hub.html +36 -0
  29. package/public/index.html +51 -1197
  30. package/public/js/agents.js +31 -0
  31. package/public/js/app.js +21 -0
  32. package/public/js/approvals.js +26 -0
  33. package/public/js/chat.js +92 -0
  34. package/public/js/config.js +100 -0
  35. package/public/js/core.js +91 -0
  36. package/public/js/events.js +31 -0
  37. package/public/js/hub.js +40 -0
  38. package/public/js/lamps.js +112 -0
  39. package/public/js/lsp.js +81 -0
  40. package/public/js/mcp.js +74 -0
  41. package/public/js/memory.js +17 -0
  42. package/public/js/panel.js +101 -0
  43. package/public/js/procs.js +45 -0
  44. package/public/js/schedules.js +118 -0
  45. package/public/js/sessions.js +69 -0
  46. package/public/js/sidebar.js +33 -0
  47. package/public/js/terminal.js +49 -0
  48. package/public/js/theme.js +6 -0
  49. package/public/js/todo.js +10 -0
  50. package/public/js/tree.js +53 -0
  51. package/public/js/update.js +14 -0
  52. package/public/js/workspaces.js +83 -0
  53. package/skills/lampson/SKILL.md +21 -0
  54. package/skills/synsema/SKILL.md +4 -1
  55. package/web.syn +165 -55
package/lib/session.syn CHANGED
@@ -72,7 +72,8 @@ export task list()
72
72
  try
73
73
  let d be load(id)
74
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
75
+ -- sin marca de proyecto (anteriores al 2026-08-27) no se listan en ningún workspace (aparecían en todos)
76
+ let project be when contains(d["meta"], "project") then d["meta"]["project"] otherwise ""
76
77
  when project == mine
77
78
  set out to append(out, {"id": id, "updated": d["updated"], "title": title})
78
79
  recover err
@@ -81,6 +82,42 @@ export task list()
81
82
  give []
82
83
  give sort_by(out, (s) => 0 - number(replace_text(replace_text(s["id"], "-", ""), "test", "")))
83
84
 
85
+ -- Búsqueda de texto en las sesiones de ESTE proyecto (título y contenido de los mensajes user/assistant):
86
+ -- [{id, updated, title, snippet}] — snippet = el primer trozo donde aparece `q`. Vacío = todas (sin snippet).
87
+ -- Lineal sobre los archivos (local, decenas o cientos de sesiones): suficiente; migrar a db si crece.
88
+ export task search(q, limit)
89
+ require file.read(".lampson")
90
+ require file.read(".lampson/*")
91
+ require env("LAMPSON_*")
92
+ let needle be lower(trim(text(q)))
93
+ let out be []
94
+ each s in list()
95
+ when length(out) >= limit
96
+ give out
97
+ when needle == ""
98
+ set out to append(out, {"id": s["id"], "updated": s["updated"], "title": s["title"], "snippet": ""})
99
+ otherwise
100
+ let hit be nothing
101
+ when contains(lower(s["title"]), needle)
102
+ set hit to ""
103
+ otherwise
104
+ try
105
+ let d be load(s["id"])
106
+ each m in d["messages"]
107
+ when hit == nothing and (m["role"] == "user" or m["role"] == "assistant") and text(m["content"]) == m["content"]
108
+ let c be text(m["content"])
109
+ let lc be lower(c)
110
+ when contains(lc, needle)
111
+ let pos be length(split(lc, needle)[0])
112
+ let a be when pos > 60 then pos - 60 otherwise 0
113
+ let b be when pos + length(needle) + 90 < length(c) then pos + length(needle) + 90 otherwise length(c)
114
+ set hit to (when a > 0 then "…" otherwise "") + replace_text(replace_text(slice(c, a, b), "\r", ""), "\n", " ") + (when b < length(c) then "…" otherwise "")
115
+ recover err
116
+ set hit to nothing
117
+ when hit != nothing
118
+ set out to append(out, {"id": s["id"], "updated": s["updated"], "title": s["title"], "snippet": hit})
119
+ give out
120
+
84
121
  -- Borrar una sesión (no hay delete_file en el runtime: se usa el shell del sistema sobre la ruta fija).
85
122
  export task delete(id)
86
123
  require exec
package/lib/settings.syn CHANGED
@@ -7,7 +7,15 @@
7
7
  --
8
8
  -- Forma: {"provider": "deepseek", "model": "deepseek-chat", "keys": {"deepseek": "sk-…"}}
9
9
 
10
- let PATH be ".lampson/config.json"
10
+ -- bajo el hub cada workspace corre con su propio .lampson/ y una junction .lampson/global → <home>/.lampson:
11
+ -- la config (keys, ⚙) es una sola para todos. Sin junction (instalación directa, tests) es .lampson/config.json
12
+ task cfg_path()
13
+ try
14
+ when file_exists(".lampson/global/config.json") or file_exists(".lampson/global")
15
+ give ".lampson/global/config.json"
16
+ recover err
17
+ give ".lampson/config.json"
18
+ give ".lampson/config.json"
11
19
 
12
20
  export task load()
13
21
  require file.read(".lampson")
@@ -18,7 +26,7 @@ export task load()
18
26
  when env("LAMPSON_NO_CONFIG", "") == "1"
19
27
  give {"provider": "", "model": "", "keys": {}}
20
28
  try
21
- let doc be json_decode(read_file(PATH))
29
+ let doc be json_decode(read_file(cfg_path()))
22
30
  -- claves siempre presentes: quien lee no tiene que chequear (y `and` no cortocircuita)
23
31
  when not contains(doc, "keys")
24
32
  set doc["keys"] to {}
@@ -33,7 +41,7 @@ export task load()
33
41
  export task save(doc)
34
42
  require file(".lampson")
35
43
  require file(".lampson/*")
36
- write_file(PATH, json_encode(doc))
44
+ write_file(cfg_path(), json_encode(doc))
37
45
  give true
38
46
 
39
47
  -- key guardada para un proveedor, como TEXTO ("" si no hay). Sellarla con as_secret antes de usarla.
@@ -68,3 +76,58 @@ export task set_default(name, model)
68
76
  set doc["model"] to model
69
77
  save(doc)
70
78
  give true
79
+
80
+ -- valores de configuración generales (rueda de la UI): tz, public_url, webhook_url, webhook_secret…
81
+ -- Vacío = borrar la clave. Nunca se devuelven secretos al navegador: `values()` marca webhook_secret como has_*.
82
+ export let KEYS be ["tz", "public_url", "webhook_url", "webhook_secret", "idle_hours"]
83
+
84
+ -- el archivo REAL, ignorando LAMPSON_NO_CONFIG: escribir con el doc en blanco de los tests pisaría provider/keys
85
+ -- (pasó el 2026-08-29 probando la rueda con un web de test)
86
+ task load_raw()
87
+ try
88
+ let doc be json_decode(read_file(cfg_path()))
89
+ when not contains(doc, "keys")
90
+ set doc["keys"] to {}
91
+ when not contains(doc, "provider")
92
+ set doc["provider"] to ""
93
+ when not contains(doc, "model")
94
+ set doc["model"] to ""
95
+ give doc
96
+ recover err
97
+ give {"provider": "", "model": "", "keys": {}}
98
+
99
+ export task set_value(key, value)
100
+ require file(".lampson")
101
+ require file(".lampson/*")
102
+ require env("LAMPSON_*")
103
+ when not contains(KEYS, key)
104
+ raise("unknown setting '" + text(key) + "'")
105
+ let doc be load_raw()
106
+ when trim(text(value)) == ""
107
+ let clean be {}
108
+ each k in keys(doc)
109
+ when k != key
110
+ set clean[k] to doc[k]
111
+ set doc to clean
112
+ otherwise
113
+ set doc[key] to trim(text(value))
114
+ save(doc)
115
+ give true
116
+
117
+ export task values()
118
+ require file.read(".lampson")
119
+ require file.read(".lampson/*")
120
+ require env("LAMPSON_*")
121
+ let doc be load_raw()
122
+ let out be {}
123
+ each k in KEYS
124
+ when k == "webhook_secret"
125
+ set out["has_webhook_secret"] to (contains(doc, k) and text(doc[k]) != "") or env("LAMPSON_WEBHOOK_SECRET", "") != ""
126
+ otherwise
127
+ set out[k] to when contains(doc, k) then text(doc[k]) otherwise ""
128
+ -- lo fijado en .env gana y se muestra como tal
129
+ let e be env("LAMPSON_" + upper(k), "")
130
+ when e != ""
131
+ set out[k] to e
132
+ set out[k + "_from_env"] to true
133
+ give out
package/lib/skills.syn CHANGED
@@ -28,6 +28,8 @@
28
28
  let ROOTS be [
29
29
  {"dir": ".lampson/skills-global", "source": "global"},
30
30
  {"dir": ".lampson/skills-claude", "source": "global"},
31
+ {"dir": ".lampson/global/skills-global", "source": "global"},
32
+ {"dir": ".lampson/global/skills-claude", "source": "global"},
31
33
  {"dir": "skills", "source": "harness"},
32
34
  {"dir": "workspace/.claude/skills", "source": "project"},
33
35
  {"dir": "workspace/.agents/skills", "source": "project"},
package/lib/tools.syn CHANGED
@@ -27,6 +27,7 @@ use "./skills.syn" as skills
27
27
  use "./mcp.syn" as mcp
28
28
  use "./lamps.syn" as lamps
29
29
  use "./lsp.syn" as lsp
30
+ use "./schedule.syn" as schedule
30
31
 
31
32
  -- la task de la tool skill (su SPEC está en tools/skill.syn)
32
33
  task skill_tool(name, action, source, scope)
@@ -155,6 +156,96 @@ let LSP_SPEC be {
155
156
  }, "required": ["op"]}
156
157
  }
157
158
 
159
+ -- la tool schedule (lib/schedule.syn): tareas programadas. add/remove/enable/run piden humano SIEMPRE (permission.syn):
160
+ -- crear una tarea = autorizar de una vez todo lo que va a hacer sin nadie mirando. Las corridas las hace el daemon
161
+ -- (web.syn); desde acá solo se corren en el acto las lamp/bash (una prompt anidaría un loop dentro del turno).
162
+ task schedule_tool(action, id, name, at, kind, lamp, tool, args, command, prompt, agent, permission, approval_timeout, notify)
163
+ require exec
164
+ require time
165
+ require net
166
+ require env("LAMPSON_*")
167
+ require env("OS")
168
+ require file(".lampson")
169
+ require file(".lampson/*")
170
+ require file.read("lamps")
171
+ require file.read("lamps/*")
172
+ require file("workspace")
173
+ require file("workspace/*")
174
+ let act be when action == nothing then "list" otherwise action
175
+ when act == "add"
176
+ let a be {"type": kind}
177
+ when kind == "lamp"
178
+ set a to {"type": "lamp", "lamp": lamp, "tool": tool, "args": when args == nothing then {} otherwise args}
179
+ otherwise when kind == "bash"
180
+ set a to {"type": "bash", "command": command}
181
+ otherwise when kind == "prompt"
182
+ set a to {"type": "prompt", "prompt": prompt, "agent": when agent == nothing then "build" otherwise agent}
183
+ let spec be {"name": name, "when": at, "action": a, "permission": when permission == nothing then "ask" otherwise permission}
184
+ when approval_timeout != nothing
185
+ set spec["approval_timeout"] to approval_timeout
186
+ when notify != nothing
187
+ set spec["notify"] to notify
188
+ let t be schedule.add(spec)
189
+ give "scheduled '" + t["name"] + "' (id " + t["id"] + "): " + schedule.describe_plan(t["plan"]) + " (local time) · " + schedule.describe_action(t["action"]) + " · permission " + t["permission"] + " · next run " + schedule.fmt_local(t["next_run"]) + " — tell the user this next-run time as is. The run's report lands in a NEW session named ⏰ <name> (sidebar «Sesiones» / /sessions) and, if notify is set, in that webhook — NOT in this chat: say so. " + daemon_note()
190
+ when act == "remove"
191
+ give schedule.remove(id)
192
+ when act == "enable"
193
+ give schedule.set_enabled(id, true)
194
+ when act == "disable"
195
+ give schedule.set_enabled(id, false)
196
+ when act == "run"
197
+ let t be schedule.get(id)
198
+ when t == nothing
199
+ raise("no scheduled task '" + text(id) + "'")
200
+ when t["action"]["type"] == "prompt"
201
+ schedule.request_run(id)
202
+ give "run requested: it runs within " + text(schedule.TICK_SECONDS) + " s in the background and its report lands in a NEW session (⏰ " + t["name"] + "), not in this chat. " + daemon_note()
203
+ schedule.mark_started(t)
204
+ let started be now()
205
+ let out be schedule.run_simple(t)
206
+ schedule.record(t, started, when starts_with(out, "ERROR") or starts_with(out, "DENIED") then "error" otherwise "ok", out, {})
207
+ give out
208
+ when act == "log"
209
+ let tail be schedule.log_tail(id, 80)
210
+ give when tail == "" then "no runs yet for " + text(id) otherwise tail
211
+ let sm be schedule.summary()
212
+ when length(sm) == 0
213
+ give "no scheduled tasks. " + daemon_note()
214
+ let lines be []
215
+ each s in sm
216
+ set lines to append(lines, s["id"] + (when s["enabled"] then " ON " otherwise " off") + " · " + s["plan"] + " · " + s["action_text"] + " · permission " + s["permission"] + " · next " + s["next_local"] + (when s["last_run"] != nothing then " · last " + s["last_status"] + ": " + s["last_summary"] otherwise ""))
217
+ give join(lines, "\n") + "\n" + daemon_note()
218
+
219
+ task daemon_note()
220
+ require time
221
+ require file(".lampson")
222
+ require file(".lampson/*")
223
+ let age be schedule.daemon_age()
224
+ when age == nothing or age > schedule.TICK_SECONDS * 3
225
+ give "NOTE: no scheduler is running right now (scheduled tasks run inside the web/daemon process): the user starts it with `lampson --daemon start` (or keeps `lampson --web` open)."
226
+ give "Scheduler alive (last tick " + text(floor(age)) + " s ago)."
227
+
228
+ let SCHEDULE_SPEC be {
229
+ "name": "schedule",
230
+ "description": "Scheduled tasks: run something on a schedule with nobody watching — a lamp tool, a fixed shell command, or a full agent run from a prompt (kind=prompt: the agent works unattended with the chosen profile and writes a report; its session appears as ⏰ <name>). `at` formats — recurring: 'every 6h' | 'every 30m' | 'daily 09:00' | 'mon,wed 08:30' | 'weekdays 09:00'; ONE-TIME (runs once, then turns itself off): 'today 15:14' | 'tomorrow 09:00' | 'once 2026-08-29 15:14' | 'in 2h'. Times are the USER'S LOCAL time (the machine's timezone): write the hour exactly as the user says it — NEVER convert to UTC. Tasks belong to the current workspace. permission = what a prompt run may do without asking: strict (dangerous actions denied), ask (default: a dangerous action sends the user an approval link/notification and waits up to approval_timeout seconds, denied if unanswered), yolo (allowed). notify = optional webhook URL that receives the result as JSON (for 'search and send me' tasks). action=add ALWAYS asks the user (it authorizes future unattended runs); remove/enable/disable/run also ask; list and log do not. The tasks are executed by Lampson's resident process (`lampson --daemon start` or the open web UI) — say so if the list shows no scheduler running. Use it when the user says 'every day at', 'each N hours', 'on Mondays', 'periodically', 'remind me', 'send me'.",
231
+ "parameters": {"type": "object", "properties": {
232
+ "action": {"type": "string", "enum": ["list", "add", "remove", "enable", "disable", "run", "log"], "description": "Default: list"},
233
+ "id": {"type": "string", "description": "remove/enable/disable/run/log: the task id (from list)"},
234
+ "name": {"type": "string", "description": "add: short human name"},
235
+ "at": {"type": "string", "description": "add: the schedule — 'every 6h' | 'daily 09:00' | 'mon,wed 08:30' | 'weekdays 09:00'"},
236
+ "kind": {"type": "string", "enum": ["lamp", "bash", "prompt"], "description": "add: what runs"},
237
+ "lamp": {"type": "string", "description": "add kind=lamp: lamp name (must be ON)"},
238
+ "tool": {"type": "string", "description": "add kind=lamp: tool of that lamp"},
239
+ "args": {"type": "object", "description": "add kind=lamp: arguments for the tool"},
240
+ "command": {"type": "string", "description": "add kind=bash: the shell command (must finish on its own; no servers)"},
241
+ "prompt": {"type": "string", "description": "add kind=prompt: self-contained instructions for the unattended agent run (what to do, how to verify, what to report)"},
242
+ "agent": {"type": "string", "enum": ["build", "plan", "review", "explore"], "description": "add kind=prompt: profile (default build)"},
243
+ "permission": {"type": "string", "enum": ["strict", "ask", "yolo"], "description": "add: permission envelope for unattended runs (default ask)"},
244
+ "approval_timeout": {"type": "integer", "description": "add: seconds to wait for a remote approval (default 7200)"},
245
+ "notify": {"type": "string", "description": "add: webhook URL that receives each result as JSON"}
246
+ }, "required": ["action"]}
247
+ }
248
+
158
249
  -- Allow-list nombre → task. El modelo solo devuelve NOMBRES; el loop decide si se ejecuta.
159
250
  export task registry()
160
251
  give {
@@ -171,10 +262,11 @@ export task registry()
171
262
  "skill": skill_tool,
172
263
  "mcp": mcp_tool,
173
264
  "lamp": lamp_tool,
174
- "lsp": lsp_tool
265
+ "lsp": lsp_tool,
266
+ "schedule": schedule_tool
175
267
  }
176
268
 
177
- export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, LAMP_SPEC]
269
+ export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, LAMP_SPEC, SCHEDULE_SPEC]
178
270
 
179
271
  -- Subconjuntos (para perfiles de agente): registry/catálogo filtrados por nombre.
180
272
  export task registry_subset(names)