session+skills: fix sqlite lock contention; add /save-discoveries builtin
ober
a7320ba6572253479bc2940989db1b37d9c50ed1
--- a/build-binary.ss +++ b/build-binary.ss @@ -109,6 +109,8 @@ "lib/jcode/core/agent" "lib/jcode/core/plugin" "lib/jcode/core/debug-repl" + "lib/jcode/core/skill" + "lib/jcode/core/builtin-skills" "lib/jcode/provider/provider" "lib/jcode/tool/registry" "lib/jcode/tool/file" new file mode 100644 --- /dev/null +++ b/src/jcode/core/builtin-skills.ss @@ -0,0 +1,114 @@ +;;; Built-in jcode skills. +;;; +;;; Skills here are compiled into the binary so /save-discoveries (and +;;; siblings) work out of the box without needing a ~/.claude/skills tree +;;; on disk. jcode's primary purpose is jerboa development, so the jerboa-mcp +;;; cookbook/security/feature workflow is a first-class slash command. +;;; +;;; To add a new builtin skill: add an entry to *builtin-skills* with the +;;; skill name and the full prompt text the agent should execute. + +(export builtin-skill + builtin-skill-list) + +(import :std/sort) + +(def *save-discoveries-prompt* + (string-append +"Review what was learned during this session and save it in three ways.\n" +"\n" +"All discoveries are written to the jerboa-mcp repository at\n" +"~/mine/jerboa-mcp via the jerboa MCP tools (jerboa_howto_add,\n" +"jerboa_suggest_feature, jerboa_security_pattern_add). Pass NO\n" +"cookbook_path / features_path / rules_path arguments — let them default\n" +"to the global jerboa-mcp paths.\n" +"\n" +"## Step 1: Save Cookbook Recipes\n" +"\n" +"For each non-trivial Jerboa pattern, workaround, or API discovery made\n" +"during this session:\n" +"\n" +"1. Call `jerboa_howto` with relevant keywords to check if a recipe already exists.\n" +"2. If not found, call `jerboa_howto_add` with:\n" +" - `id`: kebab-case identifier\n" +" - `title`: human-readable title\n" +" - `tags`: 4-6 search keywords (module name, task, alternative phrasings)\n" +" - `imports`: all required imports (use `[]` if none)\n" +" - `code`: complete, copy-pasteable working example\n" +" - `notes`: gotchas, alternatives, or non-obvious details\n" +"\n" +"Skip trivial one-liners and project-specific business logic.\n" +"\n" +"## Step 2: Suggest Tooling Improvements\n" +"\n" +"For each workflow friction point or missing tool capability noticed\n" +"during this session:\n" +"\n" +"1. Call `jerboa_list_features` with relevant keywords to check for duplicates.\n" +"2. If a matching feature already exists, call `jerboa_vote_feature` with its `id` to upvote it.\n" +"3. If not found, call `jerboa_suggest_feature` with:\n" +" - `id`: kebab-case identifier\n" +" - `title`: short description\n" +" - `description`: detailed explanation\n" +" - `impact`: `high` | `medium` | `low`\n" +" - `tags`: 3-5 search keywords\n" +" - `use_case`: when this would be useful\n" +" - `example_scenario`: concrete example of the problem\n" +" - `estimated_token_reduction`: e.g. \"~500 tokens per invocation\", \"eliminates 3 tool calls\"\n" +"\n" +"## Step 3: Add Security Patterns\n" +"\n" +"For each new vulnerability pattern, unsafe coding practice, or FFI misuse\n" +"discovered during this session:\n" +"\n" +"1. Run `jerboa_security_scan` on relevant files to check if the pattern is already detected.\n" +"2. If not already covered, call `jerboa_security_pattern_add` with:\n" +" - `id`: kebab-case identifier (e.g. \"shell-injection-format-string\")\n" +" - `title`: human-readable title\n" +" - `severity`: `critical` | `high` | `medium` | `low`\n" +" - `scope`: `scheme` (.ss/.sls), `c-shim` (.c/.h), or `ffi-boundary` (FFI declarations)\n" +" - `pattern`: regex to detect the vulnerability in source lines\n" +" - `message`: explanation of the vulnerability\n" +" - `remediation`: how to fix the issue\n" +" - Optional `tags`: search keywords for discoverability\n" +" - Optional `related_recipe`: cookbook recipe id with the safe alternative\n" +"\n" +"Skip patterns that are too project-specific to generalize.\n" +"\n" +"## What to Look For\n" +"\n" +"Recipes to save:\n" +"- Correct imports/function names that required experimentation\n" +"- Workarounds for Jerboa quirks or undocumented behavior\n" +"- Multi-step patterns combining standard library functions\n" +"- Arity or signature discoveries that weren't obvious\n" +"- Module path conventions (e.g. `(std text json)` not `:std/text/json`)\n" +"\n" +"Features to suggest or vote for:\n" +"- Multiple sequential tool calls that could be a single tool\n" +"- Missing tool parameters or modes\n" +"- Workflows that forced fallback to `jerboa_eval` or bash\n" +"- Repeated cross-session patterns that could be automated\n" +"- If the friction matches an existing feature suggestion, vote for it instead of creating a duplicate\n" +"\n" +"Security patterns to add:\n" +"- New vulnerability patterns found during code review or debugging\n" +"- Unsafe FFI calling conventions (missing null checks, type mismatches with `foreign-procedure`)\n" +"- Shell injection vectors via string concatenation or `open-process-ports`\n" +"- Resource leaks (ports, fds, mutexes) without `unwind-protect` / `dynamic-wind`\n" +"- Unsafe C shim patterns (buffer overflows, static globals, missing error checks)\n" +"\n" +"Report what was saved, suggested, voted for, and security patterns added when done.\n")) + +(def *builtin-skills* + (list + (cons "save-discoveries" *save-discoveries-prompt*))) + +(def (builtin-skill name) + "Return the prompt text for the builtin skill NAME, or #f if not builtin." + (let ((entry (assoc name *builtin-skills*))) + (and entry (cdr entry)))) + +(def (builtin-skill-list) + "Return the names of all builtin skills, sorted." + (list-sort string<? (map car *builtin-skills*))) --- a/src/jcode/core/session.ss +++ b/src/jcode/core/session.ss @@ -27,10 +27,19 @@ (path-join (jcode-home) "sessions.db")) (def (open-db) - (sqlite-open (db-path))) + ;; Each connection gets a 5s busy timeout so concurrent green-thread writers + ;; (parallel tool calls + streaming response) wait for the lock instead of + ;; failing immediately with "database is locked". + (let ((db (sqlite-open (db-path)))) + (sqlite-exec db "PRAGMA busy_timeout = 5000") + db)) (def (session-init-db) (let ((db (open-db))) + ;; WAL allows concurrent readers + one writer, much friendlier to the + ;; agent's green-thread mix than the default rollback journal. Persists + ;; in the DB file, so this only needs to run once. + (sqlite-exec db "PRAGMA journal_mode = WAL") (sqlite-exec db "CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, new file mode 100644 --- /dev/null +++ b/src/jcode/core/skill.ss @@ -0,0 +1,87 @@ +;;; jcode skill loader — reads Claude-Code-compatible SKILL.md files so users +;;; can run /<skill-name> in jcode the same way they would in Claude Code. +;;; +;;; Search paths (first hit wins): +;;; <cwd>/.claude/skills/<name>/SKILL.md project-local +;;; <cwd>/.jcode/skills/<name>/SKILL.md project-local jcode-only +;;; ~/.claude/skills/<name>/SKILL.md global Claude Code skills +;;; ~/.jcode/skills/<name>/SKILL.md global jcode-only skills +;;; +;;; A skill is just a markdown file with optional YAML frontmatter. The +;;; loader strips the frontmatter and returns the body, which the UI layer +;;; injects into the agent loop as if the user typed it. + +(export skill-load + skill-list + skill-search-paths) + +(import :std/os/path + :std/misc/string + ./config) + +(def (skill-search-paths) + (let ((home (getenv "HOME")) + (cwd (current-directory))) + (list + (path-join cwd ".claude" "skills") + (path-join cwd ".jcode" "skills") + (path-join home ".claude" "skills") + (path-join home ".jcode" "skills")))) + +(def (skill-load name) + "Return the body of <name>/SKILL.md (frontmatter stripped) or #f if not found." + (let loop ((dirs (skill-search-paths))) + (cond + ((null? dirs) #f) + (else + (let ((path (path-join (car dirs) name "SKILL.md"))) + (if (file-exists? path) + (strip-frontmatter (read-file path)) + (loop (cdr dirs)))))))) + +(def (skill-list) + "Return a sorted, de-duplicated list of available skill names across all search paths." + (let ((seen (make-hash-table))) + (for-each + (lambda (dir) + (when (file-exists? dir) + (for-each + (lambda (entry) + (let ((skill-md (path-join dir entry "SKILL.md"))) + (when (and (not (hash-ref seen entry #f)) + (file-exists? skill-md)) + (hash-put! seen entry #t)))) + (safe-directory-list dir)))) + (skill-search-paths)) + (list-sort string<? (hash-keys seen)))) + +(def (read-file path) + (call-with-input-file path + (lambda (p) + (let loop ((acc '())) + (let ((line (get-line p))) + (if (eof-object? line) + (string-join (reverse acc) "\n") + (loop (cons line acc)))))))) + +(def (strip-frontmatter text) + "If TEXT begins with a `---` line, drop everything up to and including the + matching closing `---`. Otherwise return TEXT unchanged." + (let ((lines (string-split text #\newline))) + (cond + ((or (null? lines) (not (string=? (car lines) "---"))) + text) + (else + (let loop ((rest (cdr lines))) + (cond + ((null? rest) text) ; unterminated frontmatter: bail, keep original + ((string=? (car rest) "---") + (string-join (cdr rest) "\n")) + (else (loop (cdr rest))))))))) + +(def (safe-directory-list dir) + "List entries in DIR (no . or ..). Returns '() if DIR is unreadable." + (guard (e [#t '()]) + (filter (lambda (name) + (not (or (string=? name ".") (string=? name "..")))) + (directory-list dir)))) --- a/src/jcode/ui/cli.ss +++ b/src/jcode/ui/cli.ss @@ -11,6 +11,8 @@ :jcode/core/message :jcode/core/agent :jcode/core/debug-repl + :jcode/core/skill + :jcode/core/builtin-skills :jcode/tool/registry :jcode/tool/file :jcode/tool/bash @@ -289,8 +291,35 @@ EXAMPLES: (for-each (lambda (p) (printf " ~a~n" p)) plugins)))) ((or (equal? cmd "quit") (equal? cmd "exit")) (printf "Goodbye!~n") (exit 0)) + ((equal? cmd "skills") + (let* ((builtins (builtin-skill-list)) + (file-skills (filter (lambda (n) (not (member n builtins))) + (skill-list)))) + (printf "Built-in skills:~n") + (if (null? builtins) + (printf " (none)~n") + (for-each (lambda (n) (printf " ~a~n" n)) builtins)) + (printf "~nUser skills:~n") + (if (null? file-skills) + (printf " (none)~n") + (for-each (lambda (n) (printf " ~a~n" n)) file-skills)))) (#t - (printf "Unknown command: /~a~n" cmd))))) + ;; Slash dispatch: builtins win over file-based skills. + (let* ((space-pos (string-index cmd #\space)) + (skill-name (if space-pos (substring cmd 0 space-pos) cmd)) + (skill-args (if space-pos + (string-trim (substring cmd (+ space-pos 1) (string-length cmd))) + "")) + (body (or (builtin-skill skill-name) + (skill-load skill-name)))) + (cond + (body + (let ((prompt (if (string=? skill-args "") + body + (string-append body "\n\nArguments: " skill-args)))) + (agent-run session-id prompt))) + (else + (printf "Unknown command: /~a~n" cmd)))))))) ;; --- ESC interrupt support --- --- a/src/jcode/ui/tui.ss +++ b/src/jcode/ui/tui.ss @@ -20,6 +20,8 @@ :jcode/core/models :jcode/core/agent :jcode/core/debug-repl + :jcode/core/skill + :jcode/core/builtin-skills :jcode/provider/provider :jcode/core/session :jcode/core/log @@ -548,8 +550,38 @@ (string-append (format "Search: \"~a\" (~a matches)\n" term (length results)) (string-join lines "\n")))))))) + ((equal? cmd "skills") + (let* ((builtins (builtin-skill-list)) + (file-skills (filter (lambda (n) (not (member n builtins))) + (skill-list)))) + (add-message! state + (msg-block-system + (string-append + "Built-in skills:\n " + (if (null? builtins) "(none)" (string-join builtins "\n ")) + "\n\nUser skills (~/.claude/skills, ~/.jcode/skills, .claude/skills, .jcode/skills):\n " + (if (null? file-skills) "(none)" (string-join file-skills "\n "))))))) (#t - (add-message! state (msg-block-system (format "Unknown command: /~a" cmd))))))) + ;; Slash dispatch: /<name> [args] runs a skill. Builtins win over + ;; file-based skills (so jcode's jerboa-mcp workflow is always + ;; available even without ~/.claude/skills on disk). + (let* ((space-pos (string-index cmd #\space)) + (skill-name (if space-pos (substring cmd 0 space-pos) cmd)) + (skill-args (if space-pos + (string-trim (substring cmd (+ space-pos 1) (string-length cmd))) + "")) + (body (or (builtin-skill skill-name) + (skill-load skill-name)))) + (cond + (body + (let ((prompt (if (string=? skill-args "") + body + (string-append body "\n\nArguments: " skill-args)))) + (add-message! state (msg-block-user (format "/~a" cmd))) + (app-state-scroll-offset-set! state 0) + (run-agent! state prompt))) + (else + (add-message! state (msg-block-system (format "Unknown command: /~a" cmd)))))))))) ;; ---- Provider / model switching ----