lampson 0.2.7 → 0.2.8
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/README.md +2 -1
- package/chat.syn +1 -1
- package/lib/agents.syn +5 -5
- package/lib/fs.syn +226 -0
- package/lib/loop.syn +5 -4
- package/lib/permission.syn +26 -0
- package/lib/prompt.syn +1 -0
- package/lib/tools/fetch.syn +573 -0
- package/lib/tools/url.syn +220 -0
- package/lib/tools.syn +3 -1
- package/package.json +10 -2
- package/public/css/sidebar.css +21 -0
- package/public/js/core.js +1 -1
- package/public/js/tree.js +212 -10
- package/skills/lampson/SKILL.md +8 -0
- package/web.syn +15 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
-- lib/tools/url.syn — URLs para la tool fetch y para permission.syn (puro, sin capacidades)
|
|
2
|
+
--
|
|
3
|
+
-- parse(url) → {ok, error?, url, scheme, host, port, path, query, origin, dir}
|
|
4
|
+
-- host_class(host) → "public" | "private" | "blocked"
|
|
5
|
+
-- sensitive(url) → motivo (texto) si la URL lleva una credencial, o nothing
|
|
6
|
+
-- resolve(base, href) → URL absoluta de un href (relativo o no), o nothing si no es navegable (#, javascript:)
|
|
7
|
+
--
|
|
8
|
+
-- Política de hosts (tomada de hermes url_safety.py, sin resolver DNS: el runtime no expone un resolver):
|
|
9
|
+
-- * "blocked": endpoints de metadata de la nube (169.254.169.254 y familia, metadata.google.internal).
|
|
10
|
+
-- Nunca son un destino legítimo: se deniegan SIEMPRE, incluso en yolo.
|
|
11
|
+
-- * "private": loopback, redes privadas (10/8, 172.16/12, 192.168/16, CGNAT 100.64/10), link-local,
|
|
12
|
+
-- localhost y sufijos internos. Legítimo para mirar el propio dev server → PIDE aprobación (yolo permite,
|
|
13
|
+
-- strict deniega). Un host numérico ambiguo (entero, hex, octal) cae aquí también.
|
|
14
|
+
-- * "public": el resto. Límite conocido: sin DNS no se detecta un nombre público que resuelve a una IP
|
|
15
|
+
-- privada (DNS rebinding); hermes lo cubre con un resolver, aquí queda documentado.
|
|
16
|
+
|
|
17
|
+
let SENSITIVE_PARAMS be ["access_token", "api_key", "apikey", "auth_token", "authorization", "awsaccesskeyid", "client_secret", "credential", "credentials", "jwt", "password", "passwd", "secret", "session_id", "signature", "token", "private_key", "x_amz_security_token", "x_amz_signature", "x-amz-security-token", "x-amz-signature"]
|
|
18
|
+
let SECRET_RE be "(?i)(\\bsk-(ant-|proj-)?[a-z0-9_-]{24,}|\\bghp_[a-z0-9]{20,}|\\bgithub_pat_[a-z0-9_]{20,}|\\bgho_[a-z0-9]{20,}|\\bglpat-[a-z0-9_-]{16,}|\\bxox[abprs]-[a-z0-9-]{10,}|\\bakia[0-9a-z]{16}\\b|\\baiza[0-9a-z_-]{30,}|-----begin [a-z ]*private key|\\beyj[a-z0-9_-]{20,}\\.eyj[a-z0-9_-]{20,})"
|
|
19
|
+
|
|
20
|
+
task fail(msg)
|
|
21
|
+
give {"ok": false, "error": msg}
|
|
22
|
+
|
|
23
|
+
export task parse(raw)
|
|
24
|
+
when raw == nothing
|
|
25
|
+
give fail("missing URL")
|
|
26
|
+
let u be trim(text(raw))
|
|
27
|
+
-- "https:// host" — el modelo a veces mete un espacio tras el esquema (hermes normalize_url_for_request)
|
|
28
|
+
set u to replace_re(u, "^([A-Za-z][A-Za-z0-9+.-]*://)\\s+", "\\1")
|
|
29
|
+
when u == ""
|
|
30
|
+
give fail("missing URL")
|
|
31
|
+
when length(u) > 2048
|
|
32
|
+
give fail("URL longer than 2048 chars")
|
|
33
|
+
when contains(u, " ") or contains(u, "\n") or contains(u, "\t")
|
|
34
|
+
give fail("URL contains whitespace: " + u)
|
|
35
|
+
let sp be split(u, "://")
|
|
36
|
+
when length(sp) < 2
|
|
37
|
+
give fail("not an absolute http(s) URL: " + u)
|
|
38
|
+
let scheme be lower(sp[0])
|
|
39
|
+
when scheme != "http" and scheme != "https"
|
|
40
|
+
give fail("unsupported scheme '" + scheme + "' (only http and https)")
|
|
41
|
+
let rest be join(slice(sp, 1, length(sp)), "://")
|
|
42
|
+
-- fragmento y query
|
|
43
|
+
let frag_sp be split(rest, "#")
|
|
44
|
+
set rest to frag_sp[0]
|
|
45
|
+
let q_sp be split(rest, "?")
|
|
46
|
+
let query be when length(q_sp) > 1 then join(slice(q_sp, 1, length(q_sp)), "?") otherwise ""
|
|
47
|
+
set rest to q_sp[0]
|
|
48
|
+
-- autoridad y path
|
|
49
|
+
let slash_sp be split(rest, "/")
|
|
50
|
+
let authority be slash_sp[0]
|
|
51
|
+
let path be when length(slash_sp) > 1 then "/" + join(slice(slash_sp, 1, length(slash_sp)), "/") otherwise "/"
|
|
52
|
+
when contains(authority, "@")
|
|
53
|
+
give fail("credentials in the URL are not allowed")
|
|
54
|
+
when authority == ""
|
|
55
|
+
give fail("missing host: " + u)
|
|
56
|
+
let host be lower(authority)
|
|
57
|
+
let port be ""
|
|
58
|
+
when starts_with(host, "[")
|
|
59
|
+
-- IPv6 literal: [::1]:8080
|
|
60
|
+
let close be split(host, "]")
|
|
61
|
+
set host to slice(close[0], 1, length(close[0]))
|
|
62
|
+
when length(close) > 1 and starts_with(close[1], ":")
|
|
63
|
+
set port to slice(close[1], 1, length(close[1]))
|
|
64
|
+
otherwise
|
|
65
|
+
let hp be split(host, ":")
|
|
66
|
+
when length(hp) > 2
|
|
67
|
+
give fail("malformed host: " + authority)
|
|
68
|
+
set host to hp[0]
|
|
69
|
+
when length(hp) == 2
|
|
70
|
+
set port to hp[1]
|
|
71
|
+
when port != "" and not matches(port, "[0-9]{1,5}")
|
|
72
|
+
give fail("malformed port: " + port)
|
|
73
|
+
-- "example.com." (FQDN con punto final) ≡ "example.com"
|
|
74
|
+
while ends_with(host, ".")
|
|
75
|
+
set host to slice(host, 0, length(host) - 1)
|
|
76
|
+
when host == ""
|
|
77
|
+
give fail("missing host: " + u)
|
|
78
|
+
let origin be scheme + "://" + host + (when port != "" then ":" + port otherwise "")
|
|
79
|
+
let dir_parts be split(path, "/")
|
|
80
|
+
let dir be join(slice(dir_parts, 0, length(dir_parts) - 1), "/") + "/"
|
|
81
|
+
give {"ok": true, "url": origin + path + (when query != "" then "?" + query otherwise ""), "scheme": scheme, "host": host, "port": port, "path": path, "query": query, "origin": origin, "dir": dir}
|
|
82
|
+
|
|
83
|
+
-- octetos de una IPv4 en notación decimal con puntos, o nothing
|
|
84
|
+
task ipv4_octets(host)
|
|
85
|
+
when not matches(host, "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}")
|
|
86
|
+
give nothing
|
|
87
|
+
let out be []
|
|
88
|
+
each p in split(host, ".")
|
|
89
|
+
let n be floor(number(p))
|
|
90
|
+
when n > 255
|
|
91
|
+
give nothing
|
|
92
|
+
set out to append(out, n)
|
|
93
|
+
give out
|
|
94
|
+
|
|
95
|
+
task ipv4_class(o)
|
|
96
|
+
let a be o[0]
|
|
97
|
+
let b be o[1]
|
|
98
|
+
when a == 169 and b == 254
|
|
99
|
+
give "blocked"
|
|
100
|
+
when a == 100 and b == 100 and o[2] == 100 and o[3] == 200
|
|
101
|
+
give "blocked"
|
|
102
|
+
when a == 127 or a == 10 or a == 0
|
|
103
|
+
give "private"
|
|
104
|
+
when a == 192 and b == 168
|
|
105
|
+
give "private"
|
|
106
|
+
when a == 172 and b >= 16 and b <= 31
|
|
107
|
+
give "private"
|
|
108
|
+
when a == 100 and b >= 64 and b <= 127
|
|
109
|
+
give "private"
|
|
110
|
+
when a >= 224
|
|
111
|
+
give "private"
|
|
112
|
+
give "public"
|
|
113
|
+
|
|
114
|
+
export task host_class(host)
|
|
115
|
+
let h be lower(text(host))
|
|
116
|
+
when h == "metadata.google.internal" or h == "metadata.goog" or h == "fd00:ec2::254"
|
|
117
|
+
give "blocked"
|
|
118
|
+
let o be ipv4_octets(h)
|
|
119
|
+
when o != nothing
|
|
120
|
+
give ipv4_class(o)
|
|
121
|
+
-- IPv6: loopback, unspecified, ULA fc00::/7, link-local fe80::/10, IPv4 mapeadas ::ffff:a.b.c.d
|
|
122
|
+
when contains(h, ":")
|
|
123
|
+
when h == "::1" or h == "::"
|
|
124
|
+
give "private"
|
|
125
|
+
when starts_with(h, "::ffff:")
|
|
126
|
+
let inner be ipv4_octets(slice(h, 7, length(h)))
|
|
127
|
+
give when inner != nothing then ipv4_class(inner) otherwise "private"
|
|
128
|
+
when starts_with(h, "fc") or starts_with(h, "fd") or starts_with(h, "fe8") or starts_with(h, "fe9") or starts_with(h, "fea") or starts_with(h, "feb")
|
|
129
|
+
give "private"
|
|
130
|
+
give "public"
|
|
131
|
+
when h == "localhost" or ends_with(h, ".localhost") or ends_with(h, ".internal") or ends_with(h, ".local") or ends_with(h, ".home.arpa") or ends_with(h, ".lan")
|
|
132
|
+
give "private"
|
|
133
|
+
-- formas numéricas ambiguas (2130706433, 0x7f000001, 0177.0.0.1): clásicos de bypass → tratar como privado
|
|
134
|
+
when matches(h, "[0-9]+") or matches(h, "0x[0-9a-f]+") or matches(h, "[0-9x.]+")
|
|
135
|
+
give "private"
|
|
136
|
+
give "public"
|
|
137
|
+
|
|
138
|
+
-- %XX → carácter (para inspeccionar una URL con secretos codificados)
|
|
139
|
+
task percent_decode(s)
|
|
140
|
+
let out be s
|
|
141
|
+
each m in find_all(s, "%[0-9A-Fa-f]{2}")
|
|
142
|
+
let ch be nothing
|
|
143
|
+
try
|
|
144
|
+
set ch to json_decode("\"\\u00" + slice(m, 1, 3) + "\"")
|
|
145
|
+
recover e
|
|
146
|
+
set ch to nothing
|
|
147
|
+
when ch != nothing
|
|
148
|
+
set out to replace_text(out, m, ch)
|
|
149
|
+
give out
|
|
150
|
+
|
|
151
|
+
export task sensitive(raw)
|
|
152
|
+
when raw == nothing
|
|
153
|
+
give nothing
|
|
154
|
+
let u be text(raw)
|
|
155
|
+
let decoded be percent_decode(u)
|
|
156
|
+
when length(find_all(u, SECRET_RE)) > 0 or length(find_all(decoded, SECRET_RE)) > 0
|
|
157
|
+
give "an API key or token"
|
|
158
|
+
let p be parse(u)
|
|
159
|
+
when not p["ok"]
|
|
160
|
+
give nothing
|
|
161
|
+
when p["query"] == ""
|
|
162
|
+
give nothing
|
|
163
|
+
each pair in split(p["query"], "&")
|
|
164
|
+
let kv be split(pair, "=")
|
|
165
|
+
let k be lower(trim(percent_decode(kv[0])))
|
|
166
|
+
let v be when length(kv) > 1 then join(slice(kv, 1, length(kv)), "=") otherwise ""
|
|
167
|
+
when v != "" and contains(SENSITIVE_PARAMS, k)
|
|
168
|
+
give "a credential-like query parameter (" + kv[0] + ")"
|
|
169
|
+
give nothing
|
|
170
|
+
|
|
171
|
+
-- "/a/b/../c/./d" → "/a/c/d"
|
|
172
|
+
task normalize_path(path)
|
|
173
|
+
let out be []
|
|
174
|
+
each seg in split(path, "/")
|
|
175
|
+
when seg == ".."
|
|
176
|
+
when length(out) > 0
|
|
177
|
+
set out to slice(out, 0, length(out) - 1)
|
|
178
|
+
otherwise when seg != "." and seg != ""
|
|
179
|
+
set out to append(out, seg)
|
|
180
|
+
give "/" + join(out, "/") + (when ends_with(path, "/") and length(out) > 0 then "/" otherwise "")
|
|
181
|
+
|
|
182
|
+
export task resolve(base, href)
|
|
183
|
+
when href == nothing
|
|
184
|
+
give nothing
|
|
185
|
+
let h be trim(text(href))
|
|
186
|
+
let lh be lower(h)
|
|
187
|
+
when h == "" or starts_with(h, "#") or starts_with(lh, "javascript:") or starts_with(lh, "data:") or starts_with(lh, "mailto:") or starts_with(lh, "tel:")
|
|
188
|
+
give nothing
|
|
189
|
+
when matches(h, "[A-Za-z][A-Za-z0-9+.-]*:.*")
|
|
190
|
+
give h
|
|
191
|
+
let b be parse(base)
|
|
192
|
+
when not b["ok"]
|
|
193
|
+
give h
|
|
194
|
+
when starts_with(h, "//")
|
|
195
|
+
give b["scheme"] + ":" + h
|
|
196
|
+
-- separar query/fragmento del path relativo para normalizar solo el path
|
|
197
|
+
let tail be ""
|
|
198
|
+
let core be h
|
|
199
|
+
let qi be split(h, "?")
|
|
200
|
+
let fi be split(h, "#")
|
|
201
|
+
when length(qi) > 1
|
|
202
|
+
set core to qi[0]
|
|
203
|
+
set tail to "?" + join(slice(qi, 1, length(qi)), "?")
|
|
204
|
+
otherwise when length(fi) > 1
|
|
205
|
+
set core to fi[0]
|
|
206
|
+
set tail to "#" + join(slice(fi, 1, length(fi)), "#")
|
|
207
|
+
when core == ""
|
|
208
|
+
give b["origin"] + b["path"] + tail
|
|
209
|
+
when starts_with(core, "/")
|
|
210
|
+
give b["origin"] + normalize_path(core) + tail
|
|
211
|
+
give b["origin"] + normalize_path(b["dir"] + core) + tail
|
|
212
|
+
|
|
213
|
+
-- nombre de archivo estable para la caché/spill de una URL: fetch-<host>-<digest>
|
|
214
|
+
export task slug(host)
|
|
215
|
+
let s be replace_re(lower(text(host)), "[^a-z0-9._-]", "-")
|
|
216
|
+
when length(s) > 60
|
|
217
|
+
set s to slice(s, 0, 60)
|
|
218
|
+
when s == ""
|
|
219
|
+
give "page"
|
|
220
|
+
give s
|
package/lib/tools.syn
CHANGED
|
@@ -19,6 +19,7 @@ use "./tools/ls.syn" as t_ls
|
|
|
19
19
|
use "./tools/find.syn" as t_find
|
|
20
20
|
use "./tools/grep.syn" as t_grep
|
|
21
21
|
use "./tools/bash.syn" as t_bash
|
|
22
|
+
use "./tools/fetch.syn" as t_fetch
|
|
22
23
|
use "./tools/skill.syn" as t_skill
|
|
23
24
|
use "./tools/process.syn" as t_process
|
|
24
25
|
use "./tools/memo.syn" as t_memo
|
|
@@ -256,6 +257,7 @@ export task registry()
|
|
|
256
257
|
"find": t_find.tool,
|
|
257
258
|
"grep": t_grep.tool,
|
|
258
259
|
"bash": t_bash.tool,
|
|
260
|
+
"fetch": t_fetch.tool,
|
|
259
261
|
"process": t_process.tool,
|
|
260
262
|
"memory": t_memo.tool,
|
|
261
263
|
"todo": t_todo.tool,
|
|
@@ -266,7 +268,7 @@ export task registry()
|
|
|
266
268
|
"schedule": schedule_tool
|
|
267
269
|
}
|
|
268
270
|
|
|
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, PLUGIN_SPEC, SCHEDULE_SPEC]
|
|
271
|
+
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_fetch.SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, PLUGIN_SPEC, SCHEDULE_SPEC]
|
|
270
272
|
|
|
271
273
|
-- Subconjuntos (para perfiles de agente): registry/catálogo filtrados por nombre.
|
|
272
274
|
export task registry_subset(names)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lampson",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, plugins (your own tools, any language), LSP, MCP, sub-agents.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
},
|
|
10
10
|
"homepage": "https://github.com/kitecosmic/lampson#readme",
|
|
11
11
|
"bugs": "https://github.com/kitecosmic/lampson/issues",
|
|
12
|
-
"keywords": [
|
|
12
|
+
"keywords": [
|
|
13
|
+
"agent",
|
|
14
|
+
"coding-agent",
|
|
15
|
+
"synsema",
|
|
16
|
+
"llm",
|
|
17
|
+
"cli",
|
|
18
|
+
"lsp",
|
|
19
|
+
"mcp"
|
|
20
|
+
],
|
|
13
21
|
"bin": {
|
|
14
22
|
"lampson": "bin/lampson.js"
|
|
15
23
|
},
|
package/public/css/sidebar.css
CHANGED
|
@@ -82,3 +82,24 @@
|
|
|
82
82
|
/* puertos ocupados por lampson: visibles pero sin link ni ✕ */
|
|
83
83
|
.p .port.own { color:var(--ink-3); font-weight:400; }
|
|
84
84
|
.p.own .cm { color:var(--ink-3); }
|
|
85
|
+
|
|
86
|
+
/* explorador de archivos: selección, portapapeles, arrastre, renombrar en línea y menú contextual */
|
|
87
|
+
#tree:focus { outline:none; }
|
|
88
|
+
.row.sel { background:var(--paper-2); }
|
|
89
|
+
.row.cut { opacity:.55; }
|
|
90
|
+
.row.dragging { opacity:.4; }
|
|
91
|
+
.row.drop, #tree.drop { background:var(--accent-bg); border-left-color:var(--accent); }
|
|
92
|
+
.row.flash { background:var(--accent-bg); transition:background .7s; }
|
|
93
|
+
.row input.rn { flex:1; min-width:0; font:inherit; color:var(--ink); background:var(--paper-2); border:1px solid var(--accent); border-radius:var(--r); padding:0 4px; outline:none; }
|
|
94
|
+
.row.new { color:var(--ink); }
|
|
95
|
+
.ctx { position:fixed; z-index:70; min-width:220px; background:var(--paper); border:1px solid var(--rule-2); border-radius:var(--r); box-shadow:0 10px 30px rgba(0,0,0,.35); padding:4px 0; font:400 12.5px/1.5 var(--mono); user-select:none; }
|
|
96
|
+
.ctx .it { display:flex; align-items:center; gap:14px; padding:4px 12px; cursor:pointer; color:var(--ink-2); white-space:nowrap; }
|
|
97
|
+
.ctx .it:hover { background:var(--paper-2); color:var(--ink); }
|
|
98
|
+
.ctx .it .k { margin-left:auto; color:var(--ink-3); font-size:10.5px; }
|
|
99
|
+
.ctx .it.dis { opacity:.4; pointer-events:none; }
|
|
100
|
+
.ctx .it.danger:hover { color:var(--rubric); }
|
|
101
|
+
.ctx .it.ask { color:var(--ink-2); gap:6px; }
|
|
102
|
+
.ctx .it.ask .yes { color:var(--rubric); font-weight:600; cursor:pointer; }
|
|
103
|
+
.ctx .it.ask .no { cursor:pointer; }
|
|
104
|
+
.ctx .it.ask .yes:hover, .ctx .it.ask .no:hover { text-decoration:underline; text-underline-offset:.18em; }
|
|
105
|
+
.ctx .sep { height:1px; background:var(--rule); margin:4px 0; }
|
package/public/js/core.js
CHANGED
|
@@ -22,7 +22,7 @@ function md(src) { // Markdown mínimo: code fences, inline code, bold, headers,
|
|
|
22
22
|
}
|
|
23
23
|
// un bloque en el chat: 'user' | 'assistant' | 'step' | 'meta' | 'denied' | 'approval' | 'working'
|
|
24
24
|
function add(cls, html) { showPane('log'); log.classList.remove('hero'); const d = document.createElement('div'); d.className = 'msg ' + cls; d.innerHTML = html; log.appendChild(d); $('#stage').scrollTop = $('#stage').scrollHeight; return d; }
|
|
25
|
-
function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
|
|
25
|
+
function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'fetch') return 'fetch ' + (a.url || '') + (a.format && a.format !== 'markdown' ? ' (' + a.format + ')' : ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
|
|
26
26
|
function cmdHtml(text) { const t = String(text); const long = t.length > 220 || t.split('\n').length > 4; return `<span class="cmd">${esc(t)}</span>` + (long ? ' <span class="more">ver todo</span>' : ''); }
|
|
27
27
|
function wireMore(el) { const m = el.querySelector('.more'); if (m) m.onclick = () => { el.querySelector('.cmd').classList.toggle('open'); m.textContent = el.querySelector('.cmd').classList.contains('open') ? 'menos' : 'ver todo'; }; }
|
|
28
28
|
// POST JSON → {ok, status, data}
|
package/public/js/tree.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
// tree.js — árbol de archivos (panel derecho) con estado git,
|
|
1
|
+
// tree.js — árbol de archivos (panel derecho) con estado git, el visor de archivos y el explorador:
|
|
2
|
+
// clic derecho = menú contextual (nuevo archivo/carpeta, abrir, renombrar, duplicar, copiar/cortar/pegar,
|
|
3
|
+
// copiar ruta relativa/absoluta, insertar la ruta en el chat, eliminar), arrastrar y soltar para mover
|
|
4
|
+
// (Ctrl al soltar = copiar; sobre un archivo = a su carpeta), F2 / Supr / Ctrl+C·X·V sobre la fila
|
|
5
|
+
// seleccionada. Las operaciones van a POST /api/fs (lib/fs.syn) y nunca pisan un destino existente.
|
|
6
|
+
// Las carpetas abiertas se recuerdan (localStorage lampson.tree.open) para que un cambio no las cierre.
|
|
2
7
|
let gitStatus = { changes: {} };
|
|
8
|
+
let treeRoot = ''; // ruta absoluta del workspace (para «copiar ruta absoluta»)
|
|
9
|
+
let treeSel = null; // {path, is_dir, row}: última fila clickeada (izquierdo o derecho)
|
|
10
|
+
let treeClip = null; // {path, is_dir, op: 'copy'|'cut'}
|
|
11
|
+
let treeDrag = null; // {path, is_dir} mientras se arrastra
|
|
12
|
+
const openDirs = new Set((() => { try { return JSON.parse(localStorage.getItem('lampson.tree.open') || '[]'); } catch (e) { return []; } })());
|
|
13
|
+
function saveOpen() { try { localStorage.setItem('lampson.tree.open', JSON.stringify([...openDirs])); } catch (e) {} }
|
|
14
|
+
|
|
3
15
|
async function loadGit() {
|
|
4
16
|
try { gitStatus = await (await fetch(BASE + '/api/git')).json(); } catch (e) { gitStatus = { repo: false, changes: {} }; }
|
|
5
17
|
const g = $('#git'); if (!gitStatus.repo) { g.style.display = 'none'; return; }
|
|
@@ -17,28 +29,218 @@ function gitClass(path, isDir) {
|
|
|
17
29
|
if (!isDir) { const c = ch[path]; return c === '??' ? 'gU' : c === 'A' ? 'gA' : c === 'D' ? 'gD' : c ? 'gM' : ''; }
|
|
18
30
|
const p = path + '/'; for (const k in ch) if (k.startsWith(p)) return 'gM'; return '';
|
|
19
31
|
}
|
|
32
|
+
function parentOf(p) { const i = p.lastIndexOf('/'); return i < 0 ? '.' : p.slice(0, i); }
|
|
33
|
+
function baseOf(p) { return p.slice(p.lastIndexOf('/') + 1); }
|
|
34
|
+
function cssEsc(s) { return (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&'); }
|
|
35
|
+
function rowOf(path) { return $('#tree').querySelector(`.row[data-path="${cssEsc(path)}"]`); }
|
|
36
|
+
|
|
20
37
|
async function loadTree() {
|
|
21
38
|
await loadGit();
|
|
22
|
-
const box = $('#tree'); box.innerHTML = '';
|
|
39
|
+
const box = $('#tree'); box.innerHTML = ''; box.tabIndex = 0;
|
|
23
40
|
let t; try { t = await (await fetch(BASE + '/api/tree')).json(); } catch (e) { box.innerHTML = '<div class="row none">sin árbol</div>'; return; }
|
|
24
|
-
|
|
41
|
+
treeRoot = t.root || '';
|
|
42
|
+
const render = (entries, parent) => {
|
|
25
43
|
for (const e of entries) {
|
|
26
44
|
const n = document.createElement('div'); n.className = 'node';
|
|
27
45
|
const gc = gitClass(e.path, e.is_dir); const code = (gitStatus.changes || {})[e.path];
|
|
28
|
-
const
|
|
29
|
-
r
|
|
46
|
+
const cut = treeClip && treeClip.op === 'cut' && treeClip.path === e.path;
|
|
47
|
+
const r = document.createElement('div'); r.className = 'row' + (e.is_dir ? ' dir' : '') + (gc ? ' ' + gc : '') + (cut ? ' cut' : '');
|
|
48
|
+
r.title = e.path + (code ? ' · git ' + code : ''); r.dataset.path = e.path; r.dataset.dir = e.is_dir ? '1' : '';
|
|
49
|
+
r.innerHTML = `<span class="tw">${e.is_dir ? '▸' : ''}</span><span class="nm">${esc(e.name)}${e.is_dir ? '/' : ''}</span>` + (code && !e.is_dir ? `<span class="gs">${code === '??' ? 'N' : code}</span>` : '');
|
|
30
50
|
n.appendChild(r);
|
|
51
|
+
wireRow(r, e);
|
|
31
52
|
if (e.is_dir) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
r.
|
|
35
|
-
|
|
53
|
+
const open = openDirs.has(e.path);
|
|
54
|
+
const k = document.createElement('div'); k.className = 'kids' + (open ? '' : ' hidden'); render(e.children || [], k); n.appendChild(k);
|
|
55
|
+
r.querySelector('.tw').textContent = open ? '▾' : '▸';
|
|
56
|
+
r.onclick = () => { select(r, e); toggleDir(r, k, e.path); };
|
|
57
|
+
} else { r.onclick = () => { select(r, e); openFile(e.path, r); }; }
|
|
36
58
|
parent.appendChild(n);
|
|
37
59
|
}
|
|
38
60
|
};
|
|
39
|
-
render(t.entries || [], box
|
|
61
|
+
render(t.entries || [], box);
|
|
40
62
|
if (t.truncated) { const d = document.createElement('div'); d.className = 'row none'; d.textContent = '… árbol truncado'; box.appendChild(d); }
|
|
63
|
+
if (treeSel) { const r = rowOf(treeSel.path); if (r) { treeSel.row = r; r.classList.add('sel'); } else treeSel = null; }
|
|
64
|
+
const vp = $('#vpath').textContent; const ar = vp && rowOf(vp); if (ar && $('#viewer').style.display !== 'none') ar.classList.add('active');
|
|
41
65
|
}
|
|
66
|
+
function toggleDir(r, k, path) {
|
|
67
|
+
k.classList.toggle('hidden'); const open = !k.classList.contains('hidden');
|
|
68
|
+
r.querySelector('.tw').textContent = open ? '▾' : '▸';
|
|
69
|
+
if (open) openDirs.add(path); else openDirs.delete(path); saveOpen();
|
|
70
|
+
}
|
|
71
|
+
function expandDir(path) { if (!path || path === '.') return; openDirs.add(path); saveOpen(); }
|
|
72
|
+
function select(r, e) {
|
|
73
|
+
document.querySelectorAll('#tree .row.sel').forEach(x => x.classList.remove('sel'));
|
|
74
|
+
r.classList.add('sel'); treeSel = { path: e.path, is_dir: e.is_dir, row: r };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- arrastrar y soltar: mover (Ctrl = copiar); sobre un archivo = a la carpeta del archivo ----
|
|
78
|
+
function canDrop(d, dir) {
|
|
79
|
+
if (!d) return false;
|
|
80
|
+
if (parentOf(d.path) === dir) return false; // ya está ahí
|
|
81
|
+
if (d.is_dir && (dir === d.path || dir.startsWith(d.path + '/'))) return false; // carpeta dentro de sí misma
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
function wireRow(r, e) {
|
|
85
|
+
r.oncontextmenu = ev => { ev.preventDefault(); ev.stopPropagation(); select(r, e); showMenu(ev.clientX, ev.clientY, { path: e.path, is_dir: e.is_dir, row: r }); };
|
|
86
|
+
r.draggable = true;
|
|
87
|
+
r.ondragstart = ev => { treeDrag = { path: e.path, is_dir: e.is_dir }; ev.dataTransfer.effectAllowed = 'copyMove'; ev.dataTransfer.setData('text/plain', e.path); r.classList.add('dragging'); };
|
|
88
|
+
r.ondragend = () => { treeDrag = null; r.classList.remove('dragging'); document.querySelectorAll('#tree .drop').forEach(x => x.classList.remove('drop')); };
|
|
89
|
+
// soltar sobre una carpeta = dentro de ella; sobre un archivo = en la carpeta del archivo (se ilumina esa)
|
|
90
|
+
const dir = e.is_dir ? e.path : parentOf(e.path);
|
|
91
|
+
const lit = () => (e.is_dir ? r : (rowOf(dir) || $('#tree')));
|
|
92
|
+
r.ondragover = ev => { if (!canDrop(treeDrag, dir)) return; ev.preventDefault(); ev.stopPropagation(); ev.dataTransfer.dropEffect = ev.ctrlKey ? 'copy' : 'move'; lit().classList.add('drop'); };
|
|
93
|
+
r.ondragleave = () => lit().classList.remove('drop');
|
|
94
|
+
r.ondrop = ev => { ev.preventDefault(); ev.stopPropagation(); document.querySelectorAll('#tree .drop, #tree.drop').forEach(x => x.classList.remove('drop')); dropInto(dir, ev.ctrlKey); };
|
|
95
|
+
}
|
|
96
|
+
async function dropInto(dir, copy) {
|
|
97
|
+
const d = treeDrag; treeDrag = null;
|
|
98
|
+
if (!canDrop(d, dir)) return;
|
|
99
|
+
await fsOp(copy ? { op: 'copy', path: d.path, to: dir } : { op: 'move', path: d.path, to: dir }, res => { expandDir(dir); if (!copy) afterMove(d.path, res.path); });
|
|
100
|
+
}
|
|
101
|
+
{
|
|
102
|
+
const box = $('#tree');
|
|
103
|
+
box.ondragover = ev => { if (!canDrop(treeDrag, '.')) return; ev.preventDefault(); ev.dataTransfer.dropEffect = ev.ctrlKey ? 'copy' : 'move'; box.classList.add('drop'); };
|
|
104
|
+
box.ondragleave = () => box.classList.remove('drop');
|
|
105
|
+
box.ondrop = ev => { ev.preventDefault(); box.classList.remove('drop'); dropInto('.', ev.ctrlKey); };
|
|
106
|
+
box.oncontextmenu = ev => { if (ev.target.closest('.row')) return; ev.preventDefault(); showMenu(ev.clientX, ev.clientY, null); };
|
|
107
|
+
box.onkeydown = ev => {
|
|
108
|
+
if (ev.target.tagName === 'INPUT' || !treeSel) return;
|
|
109
|
+
const s = treeSel; const key = ev.key.toLowerCase();
|
|
110
|
+
if (ev.key === 'F2') { ev.preventDefault(); renameInline(s); }
|
|
111
|
+
else if (ev.key === 'Delete') { ev.preventDefault(); const b = s.row.getBoundingClientRect(); showMenu(b.left + 60, b.bottom, s, 'delete'); }
|
|
112
|
+
else if (ev.ctrlKey && key === 'c') { ev.preventDefault(); setClip(s, 'copy'); }
|
|
113
|
+
else if (ev.ctrlKey && key === 'x') { ev.preventDefault(); setClip(s, 'cut'); }
|
|
114
|
+
else if (ev.ctrlKey && key === 'v') { ev.preventDefault(); pasteInto(s.is_dir ? s.path : parentOf(s.path)); }
|
|
115
|
+
else if (ev.key === 'Escape') { closeMenu(); }
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- operaciones (POST /api/fs) ----
|
|
120
|
+
async function fsOp(body, onOk) {
|
|
121
|
+
const r = await api(BASE + '/api/fs', body);
|
|
122
|
+
if (!r.ok) { add('denied', esc((r.data && r.data.error) || 'error')); loadTree(); return null; }
|
|
123
|
+
const res = r.data.result || {};
|
|
124
|
+
if (onOk) onOk(res);
|
|
125
|
+
await loadTree();
|
|
126
|
+
return res;
|
|
127
|
+
}
|
|
128
|
+
function afterMove(oldPath, newPath) {
|
|
129
|
+
if (treeClip && treeClip.path === oldPath) treeClip = null;
|
|
130
|
+
if (treeSel && treeSel.path === oldPath) treeSel.path = newPath;
|
|
131
|
+
const vp = $('#vpath'); const v = vp.textContent;
|
|
132
|
+
if (v === oldPath || v.startsWith(oldPath + '/')) vp.textContent = newPath + v.slice(oldPath.length);
|
|
133
|
+
}
|
|
134
|
+
function afterDelete(path) {
|
|
135
|
+
if (treeClip && treeClip.path === path) treeClip = null;
|
|
136
|
+
if (treeSel && (treeSel.path === path || treeSel.path.startsWith(path + '/'))) treeSel = null;
|
|
137
|
+
const v = $('#vpath').textContent; if (v === path || v.startsWith(path + '/')) { $('#vpath').textContent = ''; showPane('log'); }
|
|
138
|
+
}
|
|
139
|
+
function setClip(s, op) { treeClip = { path: s.path, is_dir: s.is_dir, op }; loadTree(); }
|
|
140
|
+
function canPaste(dir) {
|
|
141
|
+
if (!treeClip) return false;
|
|
142
|
+
if (treeClip.op === 'cut') return canDrop(treeClip, dir);
|
|
143
|
+
return !(treeClip.is_dir && (dir === treeClip.path || dir.startsWith(treeClip.path + '/')));
|
|
144
|
+
}
|
|
145
|
+
async function pasteInto(dir) {
|
|
146
|
+
if (!canPaste(dir)) return;
|
|
147
|
+
const c = treeClip;
|
|
148
|
+
await fsOp(c.op === 'cut' ? { op: 'move', path: c.path, to: dir } : { op: 'copy', path: c.path, to: dir }, res => { expandDir(dir); if (c.op === 'cut') { afterMove(c.path, res.path); treeClip = null; } });
|
|
149
|
+
}
|
|
150
|
+
function absPath(path) {
|
|
151
|
+
if (!treeRoot) return path;
|
|
152
|
+
const sep = treeRoot.includes('\\') ? '\\' : '/';
|
|
153
|
+
const root = treeRoot.replace(/[\\/]+$/, '');
|
|
154
|
+
return path === '.' ? root : root + sep + path.split('/').join(sep);
|
|
155
|
+
}
|
|
156
|
+
function copyText(t, row) {
|
|
157
|
+
const done = () => { if (row) { row.classList.add('flash'); setTimeout(() => row.classList.remove('flash'), 700); } };
|
|
158
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(done, () => copyFallback(t, done)); else copyFallback(t, done);
|
|
159
|
+
}
|
|
160
|
+
function copyFallback(t, done) { const ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); done(); }
|
|
161
|
+
function insertInChat(path) {
|
|
162
|
+
const ta = $('#in'); const v = ta.value;
|
|
163
|
+
ta.value = v + (v && !/\s$/.test(v) ? ' ' : '') + path + ' ';
|
|
164
|
+
ta.focus(); ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- nuevo archivo / carpeta y renombrar: un input en la propia fila ----
|
|
168
|
+
function nameInput(r, value, onDone) {
|
|
169
|
+
const nm = r.querySelector('.nm'); const inp = document.createElement('input'); inp.className = 'rn'; inp.value = value; inp.spellcheck = false;
|
|
170
|
+
nm.replaceWith(inp); inp.focus();
|
|
171
|
+
const dot = value.startsWith('.') ? -1 : value.lastIndexOf('.'); inp.setSelectionRange(0, dot > 0 ? dot : value.length);
|
|
172
|
+
let finished = false;
|
|
173
|
+
const finish = ok => { if (finished) return; finished = true; onDone(ok ? inp.value.trim() : null); };
|
|
174
|
+
inp.onkeydown = ev => { ev.stopPropagation(); if (ev.key === 'Enter') { ev.preventDefault(); finish(true); } else if (ev.key === 'Escape') { ev.preventDefault(); finish(false); } };
|
|
175
|
+
inp.onblur = () => finish(false);
|
|
176
|
+
inp.onclick = ev => ev.stopPropagation(); inp.oncontextmenu = ev => ev.stopPropagation();
|
|
177
|
+
}
|
|
178
|
+
function newEntry(dir, isDir) {
|
|
179
|
+
closeMenu();
|
|
180
|
+
const box = $('#tree');
|
|
181
|
+
let holder = box;
|
|
182
|
+
if (dir !== '.') {
|
|
183
|
+
const r = rowOf(dir); if (!r) return;
|
|
184
|
+
const k = r.parentNode.querySelector('.kids'); if (k.classList.contains('hidden')) toggleDir(r, k, dir);
|
|
185
|
+
holder = k;
|
|
186
|
+
}
|
|
187
|
+
const n = document.createElement('div'); n.className = 'node';
|
|
188
|
+
const r = document.createElement('div'); r.className = 'row new' + (isDir ? ' dir' : ''); r.innerHTML = `<span class="tw">${isDir ? '▸' : ''}</span><span class="nm"></span>`;
|
|
189
|
+
n.appendChild(r); holder.insertBefore(n, holder.firstChild);
|
|
190
|
+
nameInput(r, '', name => {
|
|
191
|
+
n.remove();
|
|
192
|
+
if (!name) return;
|
|
193
|
+
fsOp({ op: isDir ? 'mkdir' : 'create', path: dir, name }, res => { expandDir(dir); treeSel = { path: res.path, is_dir: !!res.is_dir }; if (!isDir) openFile(res.path); });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function renameInline(s) {
|
|
197
|
+
closeMenu();
|
|
198
|
+
const r = s.row || rowOf(s.path); if (!r) return;
|
|
199
|
+
const old = baseOf(s.path);
|
|
200
|
+
nameInput(r, old, name => {
|
|
201
|
+
if (!name || name === old) { loadTree(); return; }
|
|
202
|
+
fsOp({ op: 'rename', path: s.path, name }, res => afterMove(s.path, res.path));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- menú contextual ----
|
|
207
|
+
let ctxEl = null;
|
|
208
|
+
function closeMenu() { if (ctxEl) { ctxEl.remove(); ctxEl = null; } }
|
|
209
|
+
document.addEventListener('mousedown', ev => { if (ctxEl && !ctxEl.contains(ev.target)) closeMenu(); }, true);
|
|
210
|
+
document.addEventListener('keydown', ev => { if (ev.key === 'Escape') closeMenu(); });
|
|
211
|
+
window.addEventListener('blur', closeMenu); window.addEventListener('resize', closeMenu);
|
|
212
|
+
$('#tree').addEventListener('scroll', closeMenu);
|
|
213
|
+
// e = {path, is_dir, row} o null (raíz); only = 'delete' abre solo la confirmación de borrado (tecla Supr)
|
|
214
|
+
function showMenu(x, y, e, only) {
|
|
215
|
+
closeMenu();
|
|
216
|
+
const isRoot = !e; const path = isRoot ? '.' : e.path; const isDir = isRoot || e.is_dir; const row = e && e.row;
|
|
217
|
+
const dir = isDir ? path : parentOf(path); // carpeta donde se crea o se pega
|
|
218
|
+
const items = [];
|
|
219
|
+
const del = { l: 'Eliminar', k: 'Supr', danger: true, confirm: '¿eliminar ' + baseOf(path) + (isDir ? '/' : '') + '?', f: () => fsOp({ op: 'delete', path }, () => afterDelete(path)) };
|
|
220
|
+
if (only === 'delete') items.push(del);
|
|
221
|
+
else {
|
|
222
|
+
if (!isDir) items.push({ l: 'Abrir', f: () => openFile(path, row) });
|
|
223
|
+
items.push({ l: 'Nuevo archivo…', f: () => newEntry(dir, false) }, { l: 'Nueva carpeta…', f: () => newEntry(dir, true) }, 'sep');
|
|
224
|
+
if (!isRoot) items.push({ l: 'Renombrar', k: 'F2', f: () => renameInline(e) }, { l: 'Duplicar', f: () => fsOp({ op: 'copy', path }) }, 'sep', { l: 'Copiar', k: 'Ctrl+C', f: () => setClip(e, 'copy') }, { l: 'Cortar', k: 'Ctrl+X', f: () => setClip(e, 'cut') });
|
|
225
|
+
items.push({ l: 'Pegar' + (treeClip ? ' «' + baseOf(treeClip.path) + '»' : ''), k: 'Ctrl+V', dis: !canPaste(dir), f: () => pasteInto(dir) }, 'sep');
|
|
226
|
+
items.push({ l: 'Copiar ruta', f: () => copyText(path, row) }, { l: 'Copiar ruta absoluta', dis: !treeRoot, f: () => copyText(absPath(path), row) }, { l: 'Insertar en el chat', f: () => insertInChat(path) });
|
|
227
|
+
if (!isRoot) items.push('sep', del);
|
|
228
|
+
items.push('sep', { l: 'Recargar', f: loadTree });
|
|
229
|
+
}
|
|
230
|
+
const m = document.createElement('div'); m.className = 'ctx';
|
|
231
|
+
for (const it of items) {
|
|
232
|
+
if (it === 'sep') { const s = document.createElement('div'); s.className = 'sep'; m.appendChild(s); continue; }
|
|
233
|
+
const d = document.createElement('div'); d.className = 'it' + (it.dis ? ' dis' : '') + (it.danger ? ' danger' : '');
|
|
234
|
+
d.innerHTML = `<span>${esc(it.l)}</span>` + (it.k ? `<span class="k">${esc(it.k)}</span>` : '');
|
|
235
|
+
d.onclick = ev => { ev.stopPropagation(); if (it.confirm) inlineConfirm(d, it.confirm, () => { closeMenu(); it.f(); }); else { closeMenu(); it.f(); } };
|
|
236
|
+
m.appendChild(d);
|
|
237
|
+
}
|
|
238
|
+
document.body.appendChild(m); ctxEl = m;
|
|
239
|
+
const W = window.innerWidth, H = window.innerHeight, mw = m.offsetWidth, mh = m.offsetHeight;
|
|
240
|
+
m.style.left = Math.max(4, Math.min(x, W - mw - 4)) + 'px'; m.style.top = Math.max(4, Math.min(y, H - mh - 4)) + 'px';
|
|
241
|
+
if (only === 'delete') { const first = m.querySelector('.it'); if (first) first.click(); }
|
|
242
|
+
}
|
|
243
|
+
|
|
42
244
|
async function openFile(path, row) {
|
|
43
245
|
document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active')); if (row) row.classList.add('active');
|
|
44
246
|
const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
|
package/skills/lampson/SKILL.md
CHANGED
|
@@ -25,6 +25,14 @@ description: How this harness works — tools, workspace mount, permissions, age
|
|
|
25
25
|
more context. `replace_all=true` for intentional multi-replace.
|
|
26
26
|
- `read` returns up to 2000 lines; use `offset`/`limit` for big files. Outputs > 30k chars are truncated.
|
|
27
27
|
- `find` is a simple glob (`*.ts`, `test_*`, `*config*`), `grep` is regex by default (`regex=false` for literals).
|
|
28
|
+
- `fetch(url)` returns a web page as Markdown (about a tenth of the characters of its HTML: scripts, styles,
|
|
29
|
+
nav and footers stripped, links absolute, code blocks and tables kept; a site that serves Markdown is
|
|
30
|
+
taken as is). Follows up to 5 redirects. Long pages come back head+tail and the full text is saved to
|
|
31
|
+
`.lampson/spill/fetch-<host>-<id>.md` — page it with `read offset/limit` or `grep`, never re-fetch; the
|
|
32
|
+
same URL within 20 minutes is served from that file. `format=text` for prose without links, `format=html`
|
|
33
|
+
to see the raw markup. localhost / private hosts ask the user (good for checking your own dev server);
|
|
34
|
+
cloud metadata endpoints and URLs carrying tokens are refused. Binary content (images, PDF) is reported,
|
|
35
|
+
not returned — use `bash` with `curl -o` for downloads.
|
|
28
36
|
- `lsp(op=symbols, path)` is the cheapest way to understand a big file: every function/class/variable
|
|
29
37
|
with its line range — then `read` only the range you need. `definition` / `references` / `hover` /
|
|
30
38
|
`implementation` take 1-based `line` + `character` ON the identifier. If no server is configured for
|
package/web.syn
CHANGED
|
@@ -50,6 +50,7 @@ use "./lib/loop.syn" as loop
|
|
|
50
50
|
use "./lib/session.syn" as session
|
|
51
51
|
use "./lib/agents.syn" as agents
|
|
52
52
|
use "./lib/tree.syn" as tree
|
|
53
|
+
use "./lib/fs.syn" as fs
|
|
53
54
|
use "./lib/git.syn" as git
|
|
54
55
|
use "./lib/tools/proc.syn" as proc
|
|
55
56
|
use "./lib/tools/memo.syn" as memo
|
|
@@ -193,7 +194,10 @@ serve on 8080
|
|
|
193
194
|
give {"result": session.delete(text(b["id"]))}
|
|
194
195
|
|
|
195
196
|
route "GET /w/:slug/api/tree" requires auth
|
|
196
|
-
|
|
197
|
+
let t be tree.tree(6)
|
|
198
|
+
-- ruta real del proyecto (lampson.ps1/.sh exportan LAMPSON_WORKSPACE): «copiar ruta absoluta» en el explorador
|
|
199
|
+
set t["root"] to env("LAMPSON_WORKSPACE", "")
|
|
200
|
+
give t
|
|
197
201
|
|
|
198
202
|
-- Terminales del navegador. El pty NO vive en este handler: vive en un agente supervisor
|
|
199
203
|
-- (lib/term.syn), y este socket es un puente por el bus. Así la shell sobrevive a un F5 —
|
|
@@ -440,6 +444,16 @@ serve on 8080
|
|
|
440
444
|
give fail(400, "path required")
|
|
441
445
|
give tree.file_content(query.path)
|
|
442
446
|
|
|
447
|
+
-- explorador: nuevo archivo/carpeta, renombrar, mover, copiar/duplicar, borrar (lib/fs.syn; solo dentro
|
|
448
|
+
-- del workspace, nunca pisa un destino). {op, path, name?, to?, content?} → {ok, result: {path, is_dir}}
|
|
449
|
+
route "POST /w/:slug/api/fs" requires auth
|
|
450
|
+
expect body {op: text, path: text}
|
|
451
|
+
let b be json of request
|
|
452
|
+
try
|
|
453
|
+
give {"ok": true, "result": fs.apply(b)}
|
|
454
|
+
recover err
|
|
455
|
+
give fail(400, text(err))
|
|
456
|
+
|
|
443
457
|
-- configuración general (rueda de la UI): zona horaria, URL pública, webhook… → .lampson/config.json
|
|
444
458
|
route "GET /w/:slug/api/settings/values" requires auth
|
|
445
459
|
give {"values": settings.values(), "tz_detected": schedule.tz_offset()}
|