lampson 0.1.0 → 0.1.1

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.
@@ -1,105 +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
- }
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
+ }
@@ -1,32 +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
- }
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
+ }
@@ -1,148 +1,148 @@
1
- -- lib/tools/memo.syn — memoria por proyecto: notas Markdown que el agente escribe y relee
2
- --
3
- -- Dónde: lampson/memory/<slug>/<nombre>.md — <slug> sale de la ruta REAL del workspace
4
- -- (LAMPSON_WORKSPACE, que fija el launcher): "<carpeta>-<hash8>". Así cada proyecto tiene su carpeta
5
- -- aunque dos se llamen igual, y las notas viven en lampson (no ensucian el repo del usuario).
6
- --
7
- -- El system prompt lleva SOLO el índice (nombre + primera línea de cada nota); el contenido entra al
8
- -- contexto cuando el agente llama `memory(read)`. Son archivos: el humano los lee/edita a mano y la
9
- -- UI web los muestra.
10
- use "./common.syn" as c
11
-
12
- export let ROOT be "memory"
13
-
14
- task valid_name(name)
15
- when name == nothing or name == ""
16
- give false
17
- give matches(name, "[a-zA-Z0-9_-]{1,60}")
18
-
19
- -- slug del proyecto montado
20
- export task slug()
21
- require env("LAMPSON_*")
22
- let ws be env("LAMPSON_WORKSPACE", "workspace")
23
- let norm be replace_text(lower(ws), "\\", "/")
24
- let parts be where(split(norm, "/"), (x) => x != "")
25
- let base be when length(parts) > 0 then parts[length(parts) - 1] otherwise "workspace"
26
- let runs be find_all(base, "[a-z0-9]+")
27
- let clean be when length(runs) == 0 then "project" otherwise join(runs, "-")
28
- let h be slice(decode(sha256(norm), "hex"), 0, 8)
29
- give clean + "-" + h
30
-
31
- export task dir()
32
- require env("LAMPSON_*")
33
- give ROOT + "/" + slug()
34
-
35
- task path(name)
36
- give dir() + "/" + name + ".md"
37
-
38
- -- [{name, title}] — title = primera línea no vacía (sin '#')
39
- export task list()
40
- require env("LAMPSON_*")
41
- require file.read("memory")
42
- require file.read("memory/*")
43
- let out be []
44
- try
45
- each e in list_dir(dir())
46
- when not e["is_dir"] and c.ends_with(e["name"], ".md")
47
- let name be slice(e["name"], 0, length(e["name"]) - 3)
48
- let title be ""
49
- each line in split(read_file(path(name)), "\n")
50
- when title == "" and trim(line) != ""
51
- set title to trim(replace_text(line, "#", ""))
52
- set out to append(out, {"name": name, "title": when length(title) > 120 then slice(title, 0, 120) otherwise title})
53
- recover err
54
- -- el runtime lanza "Not a directory: …" (mayúscula) cuando memory/<slug> aún no existe
55
- let e be lower(text(err))
56
- when not contains(e, "not a directory") and not contains(e, "not found") and not contains(e, "no such")
57
- raise(err)
58
- give []
59
- give sort_by(out, (x) => x["name"])
60
-
61
- export task note_read(name)
62
- require env("LAMPSON_*")
63
- require file.read("memory")
64
- require file.read("memory/*")
65
- when not valid_name(name)
66
- raise("invalid note name")
67
- try
68
- give read_file(path(name))
69
- recover err
70
- raise("no such note '" + name + "'. Existing: " + join(apply((x) => x["name"], list()), ", "))
71
-
72
- export task note_write(name, content)
73
- require env("LAMPSON_*")
74
- require file("memory")
75
- require file("memory/*")
76
- when not valid_name(name)
77
- raise("invalid note name '" + text(name) + "' (letters, digits, - or _)")
78
- write_file(path(name), content)
79
- give "saved memory/" + slug() + "/" + name + ".md (" + text(length(content)) + " chars)"
80
-
81
- export task note_append(name, content)
82
- require env("LAMPSON_*")
83
- require file("memory")
84
- require file("memory/*")
85
- when not valid_name(name)
86
- raise("invalid note name")
87
- let prev be ""
88
- try
89
- set prev to read_file(path(name))
90
- recover err
91
- set prev to ""
92
- let joined be when prev == "" then content otherwise prev + "\n\n" + content
93
- write_file(path(name), joined)
94
- give "appended to " + name + ".md (" + text(length(joined)) + " chars total)"
95
-
96
- export task note_clear(name)
97
- require env("LAMPSON_*")
98
- require file("memory")
99
- require file("memory/*")
100
- when not valid_name(name)
101
- raise("invalid note name")
102
- write_file(path(name), "")
103
- give "cleared " + name + ".md (the file stays empty; the user can delete it)"
104
-
105
- -- sección del system prompt
106
- export task prompt_section()
107
- require env("LAMPSON_*")
108
- require file.read("memory")
109
- require file.read("memory/*")
110
- let items be list()
111
- let head be "\n\n# Project memory (your own notes about THIS project, in memory/" + slug() + "/ — read with memory(read), keep them current with memory(write|append))"
112
- when length(items) == 0
113
- give head + "\n(empty — when you discover something non-obvious about this project: how to run/test it, gotchas, decisions, where things live — save it with memory(write). Keep notes short and factual.)"
114
- let lines be [head]
115
- each it in items
116
- set lines to append(lines, "- " + it["name"] + ": " + it["title"])
117
- give join(lines, "\n")
118
-
119
- -- ---------- tool ----------
120
-
121
- export task tool(action, name, content)
122
- require env("LAMPSON_*")
123
- require file("memory")
124
- require file("memory/*")
125
- when action == "list"
126
- let items be list()
127
- when length(items) == 0
128
- give "no notes yet for this project"
129
- give join(apply((x) => x["name"] + ": " + x["title"], items), "\n")
130
- when action == "read"
131
- give note_read(name)
132
- when action == "write"
133
- give note_write(name, content)
134
- when action == "append"
135
- give note_append(name, content)
136
- when action == "delete"
137
- give note_clear(name)
138
- raise("unknown action '" + text(action) + "' (list | read | write | append | delete)")
139
-
140
- export let SPEC be {
141
- "name": "memory",
142
- "description": "Your persistent notes about THIS project, kept across sessions (Markdown files in Lampson's memory folder, never inside the repo). Use `write` to save non-obvious facts you discovered and will need again: how to run/build/test, environment quirks, architecture decisions, where things live, bugs and their causes, what the user prefers. `append` adds to an existing note, `read` loads one, `list` shows them, `delete` clears one. The system prompt shows the index of notes — read the relevant ones before repeating an investigation. Keep notes short, factual and current: update a note instead of writing a contradictory one.",
143
- "parameters": {"type": "object", "properties": {
144
- "action": {"type": "string", "enum": ["list", "read", "write", "append", "delete"]},
145
- "name": {"type": "string", "description": "Note id: letters, digits, - or _ (e.g. how-to-run, db-schema, gotchas)"},
146
- "content": {"type": "string", "description": "write/append: Markdown content (first line = title)"}
147
- }, "required": ["action"]}
148
- }
1
+ -- lib/tools/memo.syn — memoria por proyecto: notas Markdown que el agente escribe y relee
2
+ --
3
+ -- Dónde: lampson/memory/<slug>/<nombre>.md — <slug> sale de la ruta REAL del workspace
4
+ -- (LAMPSON_WORKSPACE, que fija el launcher): "<carpeta>-<hash8>". Así cada proyecto tiene su carpeta
5
+ -- aunque dos se llamen igual, y las notas viven en lampson (no ensucian el repo del usuario).
6
+ --
7
+ -- El system prompt lleva SOLO el índice (nombre + primera línea de cada nota); el contenido entra al
8
+ -- contexto cuando el agente llama `memory(read)`. Son archivos: el humano los lee/edita a mano y la
9
+ -- UI web los muestra.
10
+ use "./common.syn" as c
11
+
12
+ export let ROOT be "memory"
13
+
14
+ task valid_name(name)
15
+ when name == nothing or name == ""
16
+ give false
17
+ give matches(name, "[a-zA-Z0-9_-]{1,60}")
18
+
19
+ -- slug del proyecto montado
20
+ export task slug()
21
+ require env("LAMPSON_*")
22
+ let ws be env("LAMPSON_WORKSPACE", "workspace")
23
+ let norm be replace_text(lower(ws), "\\", "/")
24
+ let parts be where(split(norm, "/"), (x) => x != "")
25
+ let base be when length(parts) > 0 then parts[length(parts) - 1] otherwise "workspace"
26
+ let runs be find_all(base, "[a-z0-9]+")
27
+ let clean be when length(runs) == 0 then "project" otherwise join(runs, "-")
28
+ let h be slice(decode(sha256(norm), "hex"), 0, 8)
29
+ give clean + "-" + h
30
+
31
+ export task dir()
32
+ require env("LAMPSON_*")
33
+ give ROOT + "/" + slug()
34
+
35
+ task path(name)
36
+ give dir() + "/" + name + ".md"
37
+
38
+ -- [{name, title}] — title = primera línea no vacía (sin '#')
39
+ export task list()
40
+ require env("LAMPSON_*")
41
+ require file.read("memory")
42
+ require file.read("memory/*")
43
+ let out be []
44
+ try
45
+ each e in list_dir(dir())
46
+ when not e["is_dir"] and c.ends_with(e["name"], ".md")
47
+ let name be slice(e["name"], 0, length(e["name"]) - 3)
48
+ let title be ""
49
+ each line in split(read_file(path(name)), "\n")
50
+ when title == "" and trim(line) != ""
51
+ set title to trim(replace_text(line, "#", ""))
52
+ set out to append(out, {"name": name, "title": when length(title) > 120 then slice(title, 0, 120) otherwise title})
53
+ recover err
54
+ -- el runtime lanza "Not a directory: …" (mayúscula) cuando memory/<slug> aún no existe
55
+ let e be lower(text(err))
56
+ when not contains(e, "not a directory") and not contains(e, "not found") and not contains(e, "no such")
57
+ raise(err)
58
+ give []
59
+ give sort_by(out, (x) => x["name"])
60
+
61
+ export task note_read(name)
62
+ require env("LAMPSON_*")
63
+ require file.read("memory")
64
+ require file.read("memory/*")
65
+ when not valid_name(name)
66
+ raise("invalid note name")
67
+ try
68
+ give read_file(path(name))
69
+ recover err
70
+ raise("no such note '" + name + "'. Existing: " + join(apply((x) => x["name"], list()), ", "))
71
+
72
+ export task note_write(name, content)
73
+ require env("LAMPSON_*")
74
+ require file("memory")
75
+ require file("memory/*")
76
+ when not valid_name(name)
77
+ raise("invalid note name '" + text(name) + "' (letters, digits, - or _)")
78
+ write_file(path(name), content)
79
+ give "saved memory/" + slug() + "/" + name + ".md (" + text(length(content)) + " chars)"
80
+
81
+ export task note_append(name, content)
82
+ require env("LAMPSON_*")
83
+ require file("memory")
84
+ require file("memory/*")
85
+ when not valid_name(name)
86
+ raise("invalid note name")
87
+ let prev be ""
88
+ try
89
+ set prev to read_file(path(name))
90
+ recover err
91
+ set prev to ""
92
+ let joined be when prev == "" then content otherwise prev + "\n\n" + content
93
+ write_file(path(name), joined)
94
+ give "appended to " + name + ".md (" + text(length(joined)) + " chars total)"
95
+
96
+ export task note_clear(name)
97
+ require env("LAMPSON_*")
98
+ require file("memory")
99
+ require file("memory/*")
100
+ when not valid_name(name)
101
+ raise("invalid note name")
102
+ write_file(path(name), "")
103
+ give "cleared " + name + ".md (the file stays empty; the user can delete it)"
104
+
105
+ -- sección del system prompt
106
+ export task prompt_section()
107
+ require env("LAMPSON_*")
108
+ require file.read("memory")
109
+ require file.read("memory/*")
110
+ let items be list()
111
+ let head be "\n\n# Project memory (your own notes about THIS project, in memory/" + slug() + "/ — read with memory(read), keep them current with memory(write|append))"
112
+ when length(items) == 0
113
+ give head + "\n(empty — when you discover something non-obvious about this project: how to run/test it, gotchas, decisions, where things live — save it with memory(write). Keep notes short and factual.)"
114
+ let lines be [head]
115
+ each it in items
116
+ set lines to append(lines, "- " + it["name"] + ": " + it["title"])
117
+ give join(lines, "\n")
118
+
119
+ -- ---------- tool ----------
120
+
121
+ export task tool(action, name, content)
122
+ require env("LAMPSON_*")
123
+ require file("memory")
124
+ require file("memory/*")
125
+ when action == "list"
126
+ let items be list()
127
+ when length(items) == 0
128
+ give "no notes yet for this project"
129
+ give join(apply((x) => x["name"] + ": " + x["title"], items), "\n")
130
+ when action == "read"
131
+ give note_read(name)
132
+ when action == "write"
133
+ give note_write(name, content)
134
+ when action == "append"
135
+ give note_append(name, content)
136
+ when action == "delete"
137
+ give note_clear(name)
138
+ raise("unknown action '" + text(action) + "' (list | read | write | append | delete)")
139
+
140
+ export let SPEC be {
141
+ "name": "memory",
142
+ "description": "Your persistent notes about THIS project, kept across sessions (Markdown files in Lampson's memory folder, never inside the repo). Use `write` to save non-obvious facts you discovered and will need again: how to run/build/test, environment quirks, architecture decisions, where things live, bugs and their causes, what the user prefers. `append` adds to an existing note, `read` loads one, `list` shows them, `delete` clears one. The system prompt shows the index of notes — read the relevant ones before repeating an investigation. Keep notes short, factual and current: update a note instead of writing a contradictory one.",
143
+ "parameters": {"type": "object", "properties": {
144
+ "action": {"type": "string", "enum": ["list", "read", "write", "append", "delete"]},
145
+ "name": {"type": "string", "description": "Note id: letters, digits, - or _ (e.g. how-to-run, db-schema, gotchas)"},
146
+ "content": {"type": "string", "description": "write/append: Markdown content (first line = title)"}
147
+ }, "required": ["action"]}
148
+ }