From 84f341f464f54b85383d9f2dd5944fc6376cefc0 Mon Sep 17 00:00:00 2001 From: Hayato Ohhashi Date: Sat, 12 Sep 2026 14:24:42 +0900 Subject: [PATCH 1/4] Make Codex setup status explicit and preserve existing configuration --- Eggshell/Install.lean | 23 ++-- Eggshell/PluginCli.lean | 34 +++++- Eggshell/PluginHooks.lean | 23 +++- plugins/eggshell/.codex-plugin/plugin.json | 17 ++- plugins/eggshell/bin/egg | 7 +- plugins/eggshell/hooks/hooks.json | 5 +- plugins/eggshell/scripts/setup.py | 59 ++++++++++- plugins/eggshell/skills/eggshell/SKILL.md | 34 ++++-- scripts/package_plugin.py | 6 +- tests/test_hook_lifecycle.py | 43 ++++++++ tests/test_plugin_package.py | 118 +++++++++++++++++++++ 11 files changed, 336 insertions(+), 33 deletions(-) diff --git a/Eggshell/Install.lean b/Eggshell/Install.lean index 5caee42..7361aef 100644 --- a/Eggshell/Install.lean +++ b/Eggshell/Install.lean @@ -47,7 +47,7 @@ def initCommand : IO UInt32 := do def pluginManifest : String := r##"{ "name": "eggshell", "version": "0.1.0", - "description": "Carry useful work across Codex chats with local memory you control", + "description": "Local memory that helps AI agents reuse work and spend fewer tokens", "author": { "name": "momonpya", "url": "https://github.com/momonpya" @@ -55,18 +55,25 @@ def pluginManifest : String := r##"{ "homepage": "https://github.com/momonpya/eggshell", "repository": "https://github.com/momonpya/eggshell", "license": "Apache-2.0", - "keywords": ["codex", "agent-memory", "work-graph", "productivity"], + "keywords": [ + "codex", + "agent-memory", + "work-graph", + "productivity" + ], "interface": { "displayName": "Eggshell", - "shortDescription": "Local memory for Codex", - "longDescription": "Eggshell saves requests, tool results, and conclusions in local .egg files. Related Codex chats receive selected prior work and instructions to reuse supported findings, check changed facts, and report what remains unverified. Memory is organized locally without additional LLM calls. Requires macOS or Linux, Python 3, and Codex command hooks.", + "shortDescription": "Token-saving local memory", + "longDescription": "Eggshell helps AI agents reuse prior work and spend fewer tokens. The current integration supports Codex with local command hooks on macOS or Linux.\n\nRequests, tool results, and conclusions stay in local .egg files. Related chats receive selected findings and instructions to check changed facts and report what remains unverified. Memory organization and retrieval run locally without generative LLM calls. Ordinary task and handoff tokens still count toward model usage. There is no hosted memory service or telemetry.\n\nAfter installing, ask Codex: Set up Eggshell for this project. Setup downloads a checksummed runtime, Python dependencies, and a search model, then initializes missing project settings while preserving existing configuration. Review and enable the hooks in /hooks and start a new chat. Installation alone does not activate memory.\n\nA startup notice identifies missing setup or confirms that the session hook ran. Use !egg doctor to check configuration without changing settings. Complete an investigation and a related follow-up in a separate chat, then use !egg graph to inspect the memory actually delivered. Once configured and enabled, saving and relevant handoffs happen automatically.\n\nThis integration does not provide automatic memory in ordinary ChatGPT Chat. Other agent harnesses are not yet supported. Use !egg off to disable memory; !egg drop clears the active turn but retains saved observations and queued commits.", "developerName": "momonpya", "category": "Productivity", "capabilities": [], "websiteURL": "https://github.com/momonpya/eggshell", "brandColor": "#6B6256", "defaultPrompt": [ - "Continue this task from relevant prior work without repeating completed investigation." + "Set up Eggshell for this project.", + "Check whether Eggshell memory is working in this project.", + "Show what Eggshell handed to this task and why it was selected." ] } }"## @@ -74,7 +81,10 @@ def pluginManifest : String := r##"{ def hooksManifest : String := r#"{ "description": "Record native results and deliver relevant prior work.", "hooks": { - "SessionStart": [{"hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "timeout": 30}]}], + "SessionStart": [ + {"matcher": "^(startup|resume|clear)$", "hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-start", "timeout": 30}]}, + {"matcher": "^compact$", "hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "timeout": 30}]} + ], "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "additionalContextLimit": 48000, "timeout": 30}]}], "PreToolUse": [{"hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "additionalContextLimit": 48000, "timeout": 30}]}], "PostToolUse": [{"hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "additionalContextLimit": 48000, "timeout": 30}]}], @@ -122,6 +132,7 @@ def pluginLauncher (root : System.FilePath) : String := "#!/bin/sh\nset -eu\nEGGSHELL_PREFIX=" ++ shellQuote root.toString ++ r#" export EGGSHELL_PREFIX case "${1-}" in + codex-start) exec "$EGGSHELL_PREFIX/libexec/eggshell" codex-hook ;; codex-hook|codex-daemon|codex-worker|codex-rpc) exec "$EGGSHELL_PREFIX/libexec/eggshell" "$@" ;; *) exec "$EGGSHELL_PREFIX/libexec/eggshell" egg "$@" ;; esac diff --git a/Eggshell/PluginCli.lean b/Eggshell/PluginCli.lean index 8a68346..2230670 100644 --- a/Eggshell/PluginCli.lean +++ b/Eggshell/PluginCli.lean @@ -9,7 +9,37 @@ namespace Eggshell.Plugin def controlUsage : String := "usage: egg [init|on|off|use P|next P|next graph auto|none|VALUES|keep [EGG]|drop|" ++ "graph [VALUES]|why|find TEXT|class VALUE|union LEFT RIGHT|split UNION|" ++ - "diff [EGG]|inspect]" + "diff [EGG]|inspect|doctor]" + +/-- Inspect configuration without creating session state, writing memory, or + starting search. Installed/configured is not proof that native hooks ran. -/ +def doctor : IO String := do + let config ← Config.load (← IO.currentDir) + let session ← IO.getEnv "CODEX_THREAD_ID" + let state : Option ThreadState ← match session with + | none => pure none + | some id => do + let files ← sessionFiles id + readJson? files.state stateJsonDefaults + let mut fields := [("runtime", Lean.toJson "installed"), + ("hook_trust", Lean.toJson "review /hooks in Codex"), + ("session_state_present", Lean.toJson state.isSome), + ("handoff_observed", Lean.toJson (state.any (! ·.lastHandoff.isEmpty)))] + match config with + | none => fields := fields ++ [("configuration", Lean.toJson "missing"), + ("next_step", Lean.toJson "Set up Eggshell for this project.")] + | some config => + let profile := state.map (·.profile) |>.getD config.defaultProfile + let selection ← IO.ofExcept (Config.resolve config profile) + let mode := if state.any (! ·.enabled) || + (selection.read.isEmpty && selection.write.isNone) then "off" + else if selection.write.isNone then "read-only" else "read/write" + fields := fields ++ [("configuration", Lean.toJson "ready"), + ("config", Lean.toJson config.source.toString), + ("profile", Lean.toJson profile), ("memory", Lean.toJson mode), + ("next_step", Lean.toJson + "Review /hooks and start a new chat. Verify saving and delivery with the two-chat example.")] + pure (Lean.Json.mkObj fields |>.compress) def controlSession : IO String := do match ← IO.getEnv "CODEX_THREAD_ID" with @@ -263,7 +293,7 @@ def eggControl (arguments : List String) : IO UInt32 := do IO.println controlUsage return 0 try - IO.println (← control arguments) + IO.println (← if arguments == ["doctor"] then doctor else control arguments) pure 0 catch error => IO.eprintln s!"egg: {error}" diff --git a/Eggshell/PluginHooks.lean b/Eggshell/PluginHooks.lean index 8b6dd7e..a403533 100644 --- a/Eggshell/PluginHooks.lean +++ b/Eggshell/PluginHooks.lean @@ -124,13 +124,28 @@ def sessionStart (input : Json) : IO String := do withSession session fun files => do if let some state ← readState? files then if state.enabled then writeJson files.state (compactState state) + return emptyHook else let cwd := System.FilePath.mk ((optionalString input "cwd").getD ".") let config ← configFromHook input cwd - withSession session fun files => do - if (← readState? files).isNone then - if let some config := config then writeJson files.state (defaultState config) - pure emptyHook + let state ← withSession session fun files => do + let state ← readState? files + if state.isNone then + if let some config := config then + let state := defaultState config + writeJson files.state state + return some state + pure state + let some config := config | + return systemMessage "Eggshell memory is not configured for this project. Ask Codex: Set up Eggshell for this project." + let some state := state | return emptyHook + if !state.enabled then + return systemMessage "Eggshell memory is off for this chat. Use !egg on only if you want to enable it." + let selection ← IO.ofExcept (Config.resolve config state.profile) + let mode := if selection.read.isEmpty && selection.write.isNone then "off" + else if selection.write.isNone then "read-only" else "read/write" + return systemMessage (s!"Eggshell session hook connected: memory {mode}, profile {state.profile}. " ++ + "Use !egg doctor to check setup; !egg graph shows context actually delivered.") def acceptOffer (state : ThreadState) (turn : String) (now : Nat) (offer : DeliveryOffer) : ThreadState := diff --git a/plugins/eggshell/.codex-plugin/plugin.json b/plugins/eggshell/.codex-plugin/plugin.json index 3ac9b3f..cef0025 100644 --- a/plugins/eggshell/.codex-plugin/plugin.json +++ b/plugins/eggshell/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "eggshell", "version": "0.1.0", - "description": "Carry useful work across Codex chats with local memory you control", + "description": "Local memory that helps AI agents reuse work and spend fewer tokens", "author": { "name": "momonpya", "url": "https://github.com/momonpya" @@ -9,18 +9,25 @@ "homepage": "https://github.com/momonpya/eggshell", "repository": "https://github.com/momonpya/eggshell", "license": "Apache-2.0", - "keywords": ["codex", "agent-memory", "work-graph", "productivity"], + "keywords": [ + "codex", + "agent-memory", + "work-graph", + "productivity" + ], "interface": { "displayName": "Eggshell", - "shortDescription": "Local memory for Codex", - "longDescription": "Eggshell saves requests, tool results, and conclusions in local .egg files. Related Codex chats receive selected prior work and instructions to reuse supported findings, check changed facts, and report what remains unverified. Memory is organized locally without additional LLM calls. Requires macOS or Linux, Python 3, and Codex command hooks.", + "shortDescription": "Token-saving local memory", + "longDescription": "Eggshell helps AI agents reuse prior work and spend fewer tokens. The current integration supports Codex with local command hooks on macOS or Linux.\n\nRequests, tool results, and conclusions stay in local .egg files. Related chats receive selected findings and instructions to check changed facts and report what remains unverified. Memory organization and retrieval run locally without generative LLM calls. Ordinary task and handoff tokens still count toward model usage. There is no hosted memory service or telemetry.\n\nAfter installing, ask Codex: Set up Eggshell for this project. Setup downloads a checksummed runtime, Python dependencies, and a search model, then initializes missing project settings while preserving existing configuration. Review and enable the hooks in /hooks and start a new chat. Installation alone does not activate memory.\n\nA startup notice identifies missing setup or confirms that the session hook ran. Use !egg doctor to check configuration without changing settings. Complete an investigation and a related follow-up in a separate chat, then use !egg graph to inspect the memory actually delivered. Once configured and enabled, saving and relevant handoffs happen automatically.\n\nThis integration does not provide automatic memory in ordinary ChatGPT Chat. Other agent harnesses are not yet supported. Use !egg off to disable memory; !egg drop clears the active turn but retains saved observations and queued commits.", "developerName": "momonpya", "category": "Productivity", "capabilities": [], "websiteURL": "https://github.com/momonpya/eggshell", "brandColor": "#6B6256", "defaultPrompt": [ - "Continue this task from relevant prior work without repeating completed investigation." + "Set up Eggshell for this project.", + "Check whether Eggshell memory is working in this project.", + "Show what Eggshell handed to this task and why it was selected." ] } } diff --git a/plugins/eggshell/bin/egg b/plugins/eggshell/bin/egg index 89d2d74..f6b548e 100755 --- a/plugins/eggshell/bin/egg +++ b/plugins/eggshell/bin/egg @@ -5,14 +5,19 @@ export EGGSHELL_PREFIX runtime="$EGGSHELL_PREFIX/libexec/eggshell" marker="$EGGSHELL_PREFIX/libexec/eggshell.owner" if [ ! -x "$runtime" ] || [ ! -f "$marker" ] || [ "$(cat "$marker")" != "o8vm/eggshell" ]; then + if [ "${1-}" = codex-start ]; then + printf '%s\n' '{"systemMessage":"Eggshell is installed, but memory is not active: its local runtime is missing. Ask Codex: Set up Eggshell for this project. Then review /hooks and start a new chat."}' + exit 0 + fi if [ "${1-}" = codex-hook ]; then printf '{}\n' exit 0 fi - echo 'Eggshell runtime is not installed. Use the Eggshell setup skill first.' >&2 + echo 'Eggshell runtime is not installed. Ask Codex: Set up Eggshell for this project.' >&2 exit 1 fi case "${1-}" in + codex-start) exec "$runtime" codex-hook ;; codex-hook|codex-daemon|codex-worker|codex-rpc) exec "$runtime" "$@" ;; *) exec "$runtime" egg "$@" ;; esac diff --git a/plugins/eggshell/hooks/hooks.json b/plugins/eggshell/hooks/hooks.json index f8682a9..fca70ea 100644 --- a/plugins/eggshell/hooks/hooks.json +++ b/plugins/eggshell/hooks/hooks.json @@ -1,7 +1,10 @@ { "description": "Record native results and deliver relevant prior work.", "hooks": { - "SessionStart": [{"hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "timeout": 30}]}], + "SessionStart": [ + {"matcher": "^(startup|resume|clear)$", "hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-start", "timeout": 30}]}, + {"matcher": "^compact$", "hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "timeout": 30}]} + ], "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "additionalContextLimit": 48000, "timeout": 30}]}], "PreToolUse": [{"hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "additionalContextLimit": 48000, "timeout": 30}]}], "PostToolUse": [{"hooks": [{"type": "command", "command": "\"${PLUGIN_ROOT}/bin/egg\" codex-hook", "additionalContextLimit": 48000, "timeout": 30}]}], diff --git a/plugins/eggshell/scripts/setup.py b/plugins/eggshell/scripts/setup.py index f6cb608..9b42cec 100644 --- a/plugins/eggshell/scripts/setup.py +++ b/plugins/eggshell/scripts/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install the pinned local runtime without registering another Codex plugin.""" +"""Set up local memory for a project, or check its configuration without changes.""" import argparse import hashlib import json @@ -40,13 +40,58 @@ def extract_runtime(archive, destination, expected): destination.chmod(0o755) +def inspect_project(runtime, project, environment): + result = subprocess.run([str(runtime), 'egg', 'doctor'], cwd=project, + env=environment, text=True, capture_output=True) + if result.returncode: + raise ValueError(result.stderr.strip() or f'Eggshell setup check exited {result.returncode}.') + return json.loads(result.stdout) + + +def initialize_project(runtime, project, environment): + """Let the runtime resolve existing project/global settings; never replace them.""" + report = inspect_project(runtime, project, environment) + if report['configuration'] == 'missing': + subprocess.run([str(runtime), 'egg', 'init'], cwd=project, + env=environment, check=True) + report = inspect_project(runtime, project, environment) + return report + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prefix', default=os.environ.get('EGGSHELL_PREFIX', str(Path.home() / '.local'))) + parser.add_argument('--project', type=Path, default=Path.cwd(), + help='Project to configure (defaults to the current directory).') + parser.add_argument('--check', action='store_true', + help='Read setup status; do not download, initialize, or enable anything.') args = parser.parse_args() prefix = Path(args.prefix).expanduser() if not prefix.is_absolute(): raise ValueError('--prefix must be an absolute path.') + project = args.project.resolve(strict=True) + if not project.is_dir(): + raise ValueError('--project must be a directory.') + environment = dict(os.environ, EGGSHELL_PREFIX=str(prefix)) + # A different project's setup must not reuse the caller's chat profile. + environment.pop('CODEX_THREAD_ID', None) + installed = prefix / 'libexec/eggshell' + if args.check: + marker = prefix / 'libexec/eggshell.owner' + if (not os.access(installed, os.X_OK) or not marker.is_file() + or marker.read_text().strip() != 'o8vm/eggshell'): + print(json.dumps({'runtime': 'missing', 'configuration': 'unknown', + 'next_step': 'Ask Codex: Set up Eggshell for this project.'})) + return 1 + help_result = subprocess.run([str(installed), 'egg', '--help'], env=environment, + check=True, text=True, capture_output=True) + if 'doctor' not in help_result.stdout: + print(json.dumps({'runtime': 'update_required', 'configuration': 'unchecked', + 'next_step': 'Run this setup without --check to update the runtime.'})) + return 1 + report = inspect_project(installed, project, environment) + print(json.dumps(report, indent=2)) + return 0 if report['configuration'] == 'ready' else 1 manifest = json.loads((Path(__file__).resolve().parent.parent / 'runtime.json').read_text()) asset = manifest['targets'][target()] url = f"https://github.com/momonpya/eggshell/releases/download/{manifest['release']}/{asset['file']}" @@ -61,13 +106,19 @@ def main(): output.write(block) executable = Path(directory) / 'eggshell' extract_runtime(archive, executable, asset['sha256']) - environment = dict(os.environ, EGGSHELL_PREFIX=str(prefix)) subprocess.run([str(executable), 'install', 'runtime'], env=environment, check=True) - print(f'Runtime ready. Add {prefix}/bin to PATH, initialize your project, and review /hooks.') + report = initialize_project(installed, project, environment) + print(json.dumps(report, indent=2)) + print('Local setup complete. Memory activation is not yet verified.') + print('In Codex, review Eggshell in /hooks and start a new chat in this project.') + print('Look for "Eggshell session hook connected", then run !egg doctor.') + print('Verify a saved investigation and a related follow-up in a separate chat with !egg graph.') + print(f'For terminal controls, add {prefix}/bin to PATH.') + return 0 if __name__ == '__main__': try: - main() + raise SystemExit(main()) except (OSError, ValueError, KeyError, subprocess.CalledProcessError, tarfile.TarError) as error: raise SystemExit(f'eggshell setup: {error}') diff --git a/plugins/eggshell/skills/eggshell/SKILL.md b/plugins/eggshell/skills/eggshell/SKILL.md index 9fc8480..3c1fcf8 100644 --- a/plugins/eggshell/skills/eggshell/SKILL.md +++ b/plugins/eggshell/skills/eggshell/SKILL.md @@ -11,24 +11,38 @@ normal tasks do not need model-authored summaries or manual memory maintenance. ## Setup +Ordinary ChatGPT Chat does not run Eggshell's automatic memory hooks. If the +current surface lacks a local shell and Codex command hooks, explain that this +integration needs Codex; do not claim that selecting the plugin activates memory. + 1. Check macOS/Linux, ARM64/x86-64, Python 3, and Codex command-hook support. Resolve this skill's installed path: the plugin root is two levels above this `SKILL.md` directory. Use absolute paths for the bundled helpers. -2. Explain that setup downloads a checksummed Eggshell runtime, Python packages, +2. Check existing setup with `python3 /scripts/setup.py --check --project `. + This only inspects configuration and does not download or enable anything. + `missing` or `update_required` means setup is needed. A ready configuration + can proceed directly to hook review; do not reinstall merely to check it. + Explain that setup downloads a checksummed Eggshell runtime, Python packages, and MiniLM. The default install root is `~/.local`; preserve an existing - `EGGSHELL_PREFIX`. Once setup is authorized, run `python3 /scripts/setup.py`. - This installs only the runtime. Do not run the standalone release installer + `EGGSHELL_PREFIX`. Once setup is authorized, run + `python3 /scripts/setup.py --project `. + This installs the runtime and initializes missing project settings, preserving + existing project and global configuration. Do not run the standalone release installer after directory installation; it registers another copy of the hooks. 3. Check `codex plugin list --marketplace eggshell --json` for the older standalone installation. If migrating that installation, remove only `eggshell@eggshell` with `codex plugin remove eggshell@eggshell --json` before enabling directory hooks. Keep the runtime and `.egg` files. Do not remove unrelated plugins. -4. In the intended project, check for `.eggshell.toml`. If absent, run - `/bin/egg init`; if present, inspect it and retain the user's settings. - Add `/bin` to the relevant PATH. Custom prefixes must also be present +4. Inspect the setup report: configuration must be `ready`. Report `off` or + `read-only` accurately; do not enable saving against an existing preference. + Add `/bin` to the relevant PATH for `!egg` controls. Custom prefixes must also be present in Codex's environment as `EGGSHELL_PREFIX`. 5. Have the user review and enable Eggshell's hooks through `/hooks`, then start - a new chat. Verify memory with an actual related two-chat example: complete + a new chat. The startup message **Eggshell session hook connected** confirms + that the session hook ran. Run `!egg doctor` in that chat to inspect setup. + A configuration report alone does not prove hook trust or successful delivery. + If no startup message appears, inspect `/hooks`; never bypass its trust checks. +6. Verify memory with an actual related two-chat example: complete an investigation, run `!egg keep`, ask a related question in a separate chat in the same project, and inspect `!egg graph`. Installation alone does not prove that a handoff was received. @@ -40,6 +54,7 @@ user shell commands inside Codex are: - `!egg`: settings and staged turn. - `!egg inspect`: resolved files and stored-state identifiers. +- `!egg doctor`: read setup and current session status without changing it. - `!egg graph`: the handoff actually delivered, without rerunning retrieval. - `!egg why`: selection details. - `!egg diff`: preview the staged turn before saving. @@ -64,6 +79,11 @@ Do not recommend repeating an investigation merely because a hook timed out. A new chat in another checkout may have a different work file. An empty handoff is possible when no relevant work is found. +At startup, a missing runtime or project configuration produces a short setup +message. Other missing-runtime hook calls remain nonblocking and quiet, and +compaction never replays the setup notice. Setup messages are UI status, not +memory context. Do not call memory active merely because the plugin is installed. + For setup failures, use the actual error. A checksum mismatch must stop setup; do not bypass verification. Hooks perform no dependency downloads. Missing runtime leaves Codex usable but provides no Eggshell memory. A failed search diff --git a/scripts/package_plugin.py b/scripts/package_plugin.py index 4301b41..1e0562b 100644 --- a/scripts/package_plugin.py +++ b/scripts/package_plugin.py @@ -40,9 +40,9 @@ def package(runtime_dir, output, release): manifest['interface'].update({ 'logo': './assets/icon.png', 'composerIcon': './assets/icon.png', 'privacyPolicyURL': 'https://github.com/momonpya/eggshell/blob/main/PRIVACY.md', - 'defaultPrompt': ['Set up Eggshell for this Codex project.', - 'Show what Eggshell handed to this task and why it was selected.', - 'Help me inspect pending Eggshell work before keeping it.']}) + 'defaultPrompt': ['Set up Eggshell for this project.', + 'Check whether Eggshell memory is working in this project.', + 'Show what Eggshell handed to this task and why it was selected.']}) manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') (plugin / 'runtime.json').write_text(json.dumps(runtime, indent=2) + '\n') archive_path = output / 'eggshell-codex-plugin.zip' diff --git a/tests/test_hook_lifecycle.py b/tests/test_hook_lifecycle.py index 8fa83b6..4f6e55b 100644 --- a/tests/test_hook_lifecycle.py +++ b/tests/test_hook_lifecycle.py @@ -109,6 +109,49 @@ def egg(self): path = self.root / 'work.egg' return path.read_bytes() if path.exists() else b'' + def test_setup_status_reports_actual_hook_and_preserves_compaction(self): + started, _ = self.hook('SessionStart', source='startup') + self.assertIn('session hook connected', started['systemMessage']) + self.assertIn('memory read/write', started['systemMessage']) + self.assertNotIn('hookSpecificOutput', started) + env = dict(self.env, CODEX_THREAD_ID='chat') + before = self.state() + result = subprocess.run([BIN, 'egg', 'doctor'], env=env, cwd=self.root, + capture_output=True, text=True, check=True) + report = json.loads(result.stdout) + self.assertEqual(report['configuration'], 'ready') + self.assertTrue(report['session_state_present']) + self.assertFalse(report['handoff_observed']) + self.assertEqual(self.state(), before) + compacted, _ = self.hook('SessionStart', source='compact') + self.assertEqual(compacted, {}) + self.assertEqual(self.state()['epoch'], before['epoch'] + 1) + subprocess.run([BIN, 'egg', 'off'], env=env, cwd=self.root, + capture_output=True, check=True) + resumed, _ = self.hook('SessionStart', source='resume') + self.assertIn('memory is off', resumed['systemMessage']) + self.assertFalse(self.state()['enabled']) + + def test_missing_configuration_notice_and_readonly_doctor(self): + self.env.pop('EGGSHELL_CONFIG', None) + self.env['EGGSHELL_PREFIX'] = str(self.root / 'prefix') + (self.root / '.eggshell.toml').unlink() + doctor = subprocess.run([BIN, 'egg', 'doctor'], env=self.env, cwd=self.root, + text=True, capture_output=True, check=True) + self.assertEqual(json.loads(doctor.stdout)['configuration'], 'missing') + self.assertFalse(self.data.exists()) + message, _ = self.hook('SessionStart', source='startup') + self.assertIn('not configured', message['systemMessage']) + self.assertNotIn('hookSpecificOutput', message) + self.assertFalse(self.egg()) + state = self.data / 'sessions/chat/state.json' + state.write_text('{broken') + result = subprocess.run([BIN, 'egg', 'doctor'], cwd=self.root, + env=dict(self.env, CODEX_THREAD_ID='chat'), text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(state.read_text(), '{broken') + self.assertFalse(list(state.parent.glob('*.corrupt-*'))) + def test_partial_results_reach_egg_before_stop_and_replay_is_idempotent(self): self.start() # Existing installations have no lifecycle epoch/offers/closed fields. diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py index 48213b6..95ba788 100644 --- a/tests/test_plugin_package.py +++ b/tests/test_plugin_package.py @@ -5,6 +5,7 @@ import json import os from pathlib import Path +import re import subprocess import tarfile import tempfile @@ -68,6 +69,123 @@ def test_missing_runtime_does_not_download_or_block_hooks(self): self.assertNotEqual(result.returncode, 0) self.assertIn('not installed', result.stderr) + def test_missing_runtime_notice_is_startup_only_and_never_blocks(self): + with tempfile.TemporaryDirectory() as directory: + env = dict(os.environ, EGGSHELL_PREFIX=directory) + launcher = ROOT / 'plugins/eggshell/bin/egg' + hooks = json.loads((ROOT / 'plugins/eggshell/hooks/hooks.json').read_text()) + for source in ['startup', 'resume', 'clear', 'compact']: + matched = [g for g in hooks['hooks']['SessionStart'] + if re.search(g.get('matcher', ''), source)] + self.assertEqual(len(matched), 1) + entry = matched[0]['hooks'][0]['command'].split()[-1] + result = subprocess.run([launcher, entry], env=env, input=json.dumps({ + 'hook_event_name': 'SessionStart', 'source': source}), + capture_output=True, text=True, timeout=2) + self.assertEqual(result.returncode, 0, result.stderr) + reply = json.loads(result.stdout) + if source == 'compact': + self.assertEqual(reply, {}) + else: + self.assertIn('memory is not active', reply['systemMessage']) + self.assertIn('Set up Eggshell', reply['systemMessage']) + self.assertNotIn('continue', reply) + self.assertNotIn('hookSpecificOutput', reply) + self.assertEqual(list(Path(directory).iterdir()), []) + + def test_startup_launcher_forwards_the_original_hook_input(self): + with tempfile.TemporaryDirectory(prefix="egg prefix's ") as directory: + root = Path(directory) + runtime = root / 'libexec/eggshell' + runtime.parent.mkdir() + runtime.write_text('#!/bin/sh\n[ "$1" = codex-hook ] || exit 9\ncat\n') + runtime.chmod(0o755) + (root / 'libexec/eggshell.owner').write_text('o8vm/eggshell\n') + payload = json.dumps({'hook_event_name': 'SessionStart', 'cwd': "a b'c", + 'session_id': 'a-chat'}) + result = subprocess.run([ROOT / 'plugins/eggshell/bin/egg', 'codex-start'], + input=payload, text=True, capture_output=True, + env=dict(os.environ, EGGSHELL_PREFIX=directory)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, payload) + + def test_project_setup_preserves_parent_and_global_configuration(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + project = root / 'project' + project.mkdir() + env = dict(os.environ, EGGSHELL_PREFIX=str(root / 'prefix'), + EGGSHELL_DATA_ROOT=str(root / 'data')) + env.pop('EGGSHELL_CONFIG', None) + env.pop('CODEX_THREAD_ID', None) + runtime = ROOT / '.lake/build/bin/eggshell' + report = setup.initialize_project(runtime, project, env) + self.assertEqual(report['configuration'], 'ready') + self.assertFalse(report['session_state_present']) + self.assertFalse(report['handoff_observed']) + config = project / '.eggshell.toml' + custom = config.read_text().replace('default = "work"', 'default = "private"') + config.write_text(custom) + nested = project / 'nested' + nested.mkdir() + report = setup.initialize_project(runtime, nested, env) + self.assertEqual(report['memory'], 'read-only') + self.assertEqual(config.read_text(), custom) + self.assertFalse((nested / '.eggshell.toml').exists()) + self.assertFalse((project / '.eggs/work.egg').exists()) + other = root / 'other' + other.mkdir() + global_config = root / 'prefix/config/eggshell/config.toml' + global_config.parent.mkdir(parents=True) + global_config.write_text(custom) + report = setup.initialize_project(runtime, other, env) + self.assertEqual(report['memory'], 'read-only') + self.assertFalse((other / '.eggshell.toml').exists()) + self.assertEqual(global_config.read_text(), custom) + self.assertFalse((root / 'data').exists()) + + def test_setup_check_does_not_install_or_initialize(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + result = subprocess.run(['python3', ROOT / 'plugins/eggshell/scripts/setup.py', + '--check', '--prefix', str(root / 'missing'), '--project', str(root)], + text=True, capture_output=True) + self.assertEqual(result.returncode, 1) + self.assertEqual(json.loads(result.stdout)['runtime'], 'missing') + self.assertEqual(list(root.iterdir()), []) + + def test_invalid_existing_configuration_is_reported_and_retained(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + config = root / '.eggshell.toml' + config.write_text('default = true\n') + env = dict(os.environ, EGGSHELL_PREFIX=str(root / 'prefix'), + EGGSHELL_DATA_ROOT=str(root / 'data')) + env.pop('EGGSHELL_CONFIG', None) + env.pop('CODEX_THREAD_ID', None) + with self.assertRaisesRegex(ValueError, 'expected quoted TOML string'): + setup.initialize_project(ROOT / '.lake/build/bin/eggshell', root, env) + self.assertEqual(config.read_text(), 'default = true\n') + self.assertFalse((root / '.eggs').exists()) + self.assertFalse((root / 'data').exists()) + + def test_setup_check_identifies_an_older_runtime_without_modifying_it(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + runtime = root / 'libexec/eggshell' + runtime.parent.mkdir() + old = '#!/bin/sh\n[ "$2" = --help ] || exit 99\nprintf "usage: egg [init|inspect]\\n"\n' + runtime.write_text(old) + runtime.chmod(0o755) + (runtime.parent / 'eggshell.owner').write_text('o8vm/eggshell\n') + result = subprocess.run(['python3', ROOT / 'plugins/eggshell/scripts/setup.py', + '--check', '--prefix', str(root), '--project', str(root)], + text=True, capture_output=True) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(json.loads(result.stdout)['runtime'], 'update_required') + self.assertEqual(runtime.read_text(), old) + self.assertFalse((root / '.eggshell.toml').exists()) + def test_runtime_install_preserves_plugin_and_memory(self): with tempfile.TemporaryDirectory(prefix="egg package's ") as directory: root = Path(directory) From c16b56bf9cc5d3ee4a7be4000704f10389ed3ef1 Mon Sep 17 00:00:00 2001 From: Hayato Ohhashi Date: Sat, 12 Sep 2026 14:24:42 +0900 Subject: [PATCH 2/4] Add logo-focused light and dark social previews --- .../github-social-preview-dark-1280x640.png | Bin 0 -> 50043 bytes .../github-social-preview-dark-1280x640.svg | 10 ++++ .../github-social-preview-light-1280x640.png | Bin 0 -> 50732 bytes .../github-social-preview-light-1280x640.svg | 10 ++++ docs/brand.md | 27 +++++++---- scripts/render_social_preview.py | 43 ++++++++++++++++++ 6 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 docs/assets/brand/github-social-preview-dark-1280x640.png create mode 100644 docs/assets/brand/github-social-preview-dark-1280x640.svg create mode 100644 docs/assets/brand/github-social-preview-light-1280x640.png create mode 100644 docs/assets/brand/github-social-preview-light-1280x640.svg create mode 100644 scripts/render_social_preview.py diff --git a/docs/assets/brand/github-social-preview-dark-1280x640.png b/docs/assets/brand/github-social-preview-dark-1280x640.png new file mode 100644 index 0000000000000000000000000000000000000000..386dae4d70526c3d036b61739c9ddd29577e8c78 GIT binary patch literal 50043 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%0ALV^?sA#(iiUitIq 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

$T0l}u>j~|e+@z&1s7Ur=?#1|e!;;_ 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 + Eggshell — AI memory. Fewer tokens. + Eggshell mascot and wordmark above the caption: AI memory. Fewer tokens. + + + + + + AI memory. Fewer tokens. + diff --git a/docs/assets/brand/github-social-preview-light-1280x640.png b/docs/assets/brand/github-social-preview-light-1280x640.png new file mode 100644 index 0000000000000000000000000000000000000000..1a1a78965a4439357aecdfb9de427d028cad5184 GIT binary patch literal 50732 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 q|H_ + Eggshell — AI memory. Fewer tokens. + Eggshell mascot and wordmark above the caption: AI memory. Fewer tokens. + + + + + + AI memory. Fewer tokens. + diff --git a/docs/brand.md b/docs/brand.md index cf3eaf9..ae71f3f 100644 --- a/docs/brand.md +++ b/docs/brand.md @@ -1,5 +1,8 @@

- Eggshell carries completed work across independent Codex chats + + + Eggshell — AI memory. Fewer tokens. +

# Brand assets @@ -22,9 +25,10 @@ All canonical assets live in [`docs/assets/brand`](assets/brand/). Use the suppl | [Symbol](assets/brand/eggshell-symbol.svg) | Small square placements where the name appears nearby. | | [Wordmark](assets/brand/eggshell-wordmark.svg) | Narrow text-only placement when the symbol is already established. | | [Dark app icon](assets/brand/eggshell-app-icon-dark-1024.png) | App icon source and square avatars. | -| [GitHub social preview](assets/brand/github-social-preview-1280x640.png) | Repository social preview and link cards. | -| [GitHub social preview source](assets/brand/github-social-preview-1280x640.svg) | Editable layout source for regenerating the PNG without altering the logo. | -| [Recorded walkthrough](demo.md) | README animation, static overview, and shareable 30-second MP4 based on the LLVM study. | +| [Light social preview](assets/brand/github-social-preview-light-1280x640.png) | Current repository social preview and light link cards. | +| [Dark social preview](assets/brand/github-social-preview-dark-1280x640.png) | Dark link cards. | +| [Light source](assets/brand/github-social-preview-light-1280x640.svg) / [dark source](assets/brand/github-social-preview-dark-1280x640.svg) | Editable layouts that preserve the canonical logo. | +| [Recorded walkthrough](demo.md) | Animation, static overview, and shareable 30-second MP4 based on the LLVM study. | | [Cross-chat handoff](assets/brand/cross-chat-handoff.svg) | Illustrative diagram showing what moves between independent chats. | | [How it works](assets/brand/how-it-works.svg) | Three-step product explanation for documentation and presentations. | @@ -56,12 +60,19 @@ The bare symbol is appropriate only where “Eggshell” is clear from the surro - Use black artwork on light surfaces and white artwork on dark surfaces. - Keep the mascot and wordmark together in their supplied relationship. - Use the symbol alone only at sizes where its facial features remain legible. -- Add descriptive alt text such as `Eggshell carries completed work across independent Codex chats`. +- Add descriptive alt text such as `Eggshell — local memory for AI agents that saves tokens`. - Do not stretch, rotate, outline, shadow, crop, recolor individual parts, or typeset a replacement wordmark. ## GitHub repository preview -GitHub does not automatically read a social-preview image from the repository. Upload [`github-social-preview-1280x640.png`](assets/brand/github-social-preview-1280x640.png) in the repository's **Settings → General → Social preview** control. +GitHub does not automatically read a social-preview image from the repository. Upload [`github-social-preview-light-1280x640.png`](assets/brand/github-social-preview-light-1280x640.png) in the repository's **Settings → General → Social preview** control. + +The preview keeps the canonical horizontal mascot and wordmark together, with +the caption **“AI memory. Fewer tokens.”** below. The README uses the same +headline beneath its existing theme-aware horizontal logo. Installation +documentation identifies the available integrations and their validation status. -The README uses the same visual system together with a product handoff diagram, -while shared repository links retain the compact brand-first preview. +Regenerate both the self-contained SVG and PNG with +`python3 scripts/render_social_preview.py` (requires `rsvg-convert`). GitHub +stores one social preview image; the separate dark export is available for +other placements. diff --git a/scripts/render_social_preview.py b/scripts/render_social_preview.py new file mode 100644 index 0000000..ab5b825 --- /dev/null +++ b/scripts/render_social_preview.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Export light/dark social cards while leaving the README hero unchanged.""" +import copy +from pathlib import Path +import subprocess +import xml.etree.ElementTree as ET + +DEST = Path(__file__).resolve().parents[1] / 'docs/assets/brand' +NS = '{http://www.w3.org/2000/svg}' +ET.register_namespace('', NS[1:-1]) + + +def render(theme, artwork, background, foreground): + logo = copy.deepcopy(ET.parse(DEST / artwork).getroot()) + # Enlarge the original lockup while keeping its center and caption fixed. + logo_width = 1060 + logo_height = logo_width * 360 / 938.119 + original_center_y = 96 + (920 * 360 / 938.119) / 2 + logo.attrib.update(x=str((1280 - logo_width) / 2), + y=str(original_center_y - logo_height / 2), + width=str(logo_width), height=str(logo_height)) + logo.attrib.pop('aria-labelledby', None) + for child in list(logo): + if child.tag in (NS + 'title', NS + 'desc'): + logo.remove(child) + svg = ('\n' + ' Eggshell — AI memory. Fewer tokens.\n' + ' Eggshell mascot and wordmark above the caption: ' + 'AI memory. Fewer tokens.\n' + f' \n' + + ET.tostring(logo, encoding='unicode') + '\n' + f' ' + 'AI memory. Fewer tokens.\n\n') + path = DEST / f'github-social-preview-{theme}-1280x640.svg' + path.write_text(svg) + subprocess.run(['rsvg-convert', str(path), '-o', str(path.with_suffix('.png'))], check=True) + + +if __name__ == '__main__': + render('dark', 'eggshell-primary-horizontal-white.svg', '#111111', '#f7f3ea') + render('light', 'eggshell-primary-horizontal.svg', '#f7f3ea', '#111111') From 64d3737021c697eba4c9fa08a37924b1a9c6874f Mon Sep 17 00:00:00 2001 From: Hayato Ohhashi Date: Sat, 12 Sep 2026 14:24:52 +0900 Subject: [PATCH 3/4] Add independent adapters for Claude Code, Gemini CLI, Cursor and OpenCode --- .github/workflows/adapters.yml | 38 +++ PRIVACY.md | 39 ++- README.md | 77 +++--- adapters/.gitignore | 1 + adapters/README.md | 174 +++++++++++++ adapters/eggshell_adapter.py | 395 +++++++++++++++++++++++++++++ adapters/install.py | 170 +++++++++++++ adapters/native/.gitignore | 1 + adapters/native/Main.lean | 142 +++++++++++ adapters/native/lake-manifest.json | 13 + adapters/native/lakefile.lean | 11 + adapters/native/lean-toolchain | 1 + adapters/opencode.mjs | 131 ++++++++++ docs/codex-plugin.md | 32 ++- tests/test_adapters.py | 287 +++++++++++++++++++++ tests/test_opencode_adapter.mjs | 93 +++++++ 16 files changed, 1550 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/adapters.yml create mode 100644 adapters/.gitignore create mode 100644 adapters/README.md create mode 100644 adapters/eggshell_adapter.py create mode 100644 adapters/install.py create mode 100644 adapters/native/.gitignore create mode 100644 adapters/native/Main.lean create mode 100644 adapters/native/lake-manifest.json create mode 100644 adapters/native/lakefile.lean create mode 100644 adapters/native/lean-toolchain create mode 100644 adapters/opencode.mjs create mode 100644 tests/test_adapters.py create mode 100644 tests/test_opencode_adapter.mjs diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml new file mode 100644 index 0000000..71c844a --- /dev/null +++ b/.github/workflows/adapters.yml @@ -0,0 +1,38 @@ +name: Adapter contracts + +on: + push: + branches: [main] + paths: + - 'adapters/**' + - 'Eggshell/**' + - 'tests/test_adapters.py' + - 'tests/test_opencode_adapter.mjs' + - 'tests/test_hook_lifecycle.py' + - '.github/workflows/adapters.yml' + - 'lean-toolchain' + - 'lakefile.lean' + pull_request: + +permissions: + contents: read + +jobs: + adapters: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: leanprover/lean-action@v1 + with: + auto-config: false + - name: Build independent adapter companion + working-directory: adapters/native + run: lake build + - name: Engine integration contracts + run: python3 tests/test_adapters.py -v + - name: OpenCode output contracts + run: node --test tests/test_opencode_adapter.mjs diff --git a/PRIVACY.md b/PRIVACY.md index 4dfb9d7..f407957 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,10 +5,12 @@ analytics, advertising identifier, or account system. ## Data Eggshell observes -When its Codex hooks are enabled, Eggshell can receive the current user prompt, +When an integration is enabled, Eggshell can receive the current user prompt, supported tool inputs and results, the final assistant message, session and turn -identifiers, and the working directory. The stable hook API does not expose -hidden chain-of-thought. Eggshell does not scrape Codex's private transcript. +identifiers, and the working directory. It does not scrape private transcripts +or collect hidden chain-of-thought. The separate +[harness adapters](adapters/README.md) forward selected event fields, excluding +account details, transcript paths, and reasoning fields. The active turn is staged under the local Eggshell data root described below. In a writable profile, observed tool results are journaled and queued for saving @@ -28,14 +30,15 @@ MiniLM model. After installation, prompts, tool results, embeddings, and `.egg` files are not sent to an Eggshell server. The daemon listens only on the local loopback interface. -**Selected prior work is sent to Codex as model input.** It may contain earlier +**Selected prior work is sent to your agent as model input.** It may contain earlier prompts, source code, commands, and tool results. That context is processed under -the settings and terms of your Codex provider. Local memory storage does not -make Codex inference local. Normal task and handoff tokens still count toward -Codex usage; Eggshell makes no additional LLM calls to organize memory. +the settings and terms of the agent's model provider. Local memory storage does +not make model inference local. Normal task and handoff tokens still count +toward model usage; Eggshell makes no additional LLM calls to organize memory. A read-only profile prevents new saves but still permits existing memory to be -sent to Codex. Use `!egg off` to disable both recording and handoff delivery. +sent to the agent. Use `!egg off` in Codex, or the adapter's `control ... off` +command, to disable both recording and handoff delivery for that session. ## Stored data @@ -44,9 +47,15 @@ sent to Codex. Use `!egg off` to disable both recording and handoff delivery. explicit configuration. Missing read paths are not created. - Staged turns, recovery state, and daemon coordination live under `EGGSHELL_DATA_ROOT`, which defaults to - `$EGGSHELL_PREFIX/share/eggshell/plugin`. This stable root is shared by Codex hooks - and the `!egg` shell command. It does not select or relocate any saved `.egg` - file. + `$EGGSHELL_PREFIX/share/eggshell/plugin`. Integrations use this stable root, + with separate adapter session namespaces for each harness. It does not select + or relocate any saved `.egg` file. +- Adapters store opaque turn/call identifiers and correlation hashes in + per-session SQLite files under the data root's `adapters` directory. Writable + turns may also retain an authorized turn snapshot for late tool results and + a temporary answer candidate under their session directory. Ambiguous Gemini + tool results, when recording is permitted, stay in `adapters/unattributed` + without being assigned to another task or inserted into the memory graph. - The MiniLM runtime and model live under `$EGGSHELL_PREFIX/share/eggshell/minilm`. - `EGGSHELL_PREFIX` defaults to `~/.local` and may point to another absolute @@ -59,15 +68,17 @@ permissions. Eggshell-owned session directories and newly created work-file directories use `0700` permissions. `egg uninstall codex` removes the Plugin and launcher but intentionally keeps -user-owned `.egg` files and recovery data. To erase Eggshell data completely, +user-owned `.egg` files and recovery data. The adapter uninstaller similarly +removes its integration entries while retaining memory and shared runtime files. +To erase Eggshell data completely, remove the `.egg` paths listed by `!egg inspect` and the local Eggshell data directory after uninstalling. Inspect those exact paths before deleting them. ## Control boundary Prior outcomes are fallible historical data, not instructions. Recorded tool -occurrences are never rewritten by semantic matching. A hook failure does not -stop Codex. Eggshell does not invent missing results or mark an incomplete task +occurrences are never rewritten by semantic matching. Hook errors return without +blocking ordinary agent execution. Eggshell does not invent missing results or mark an incomplete task as completed when saving observed work. Security issues should be reported through GitHub's private vulnerability diff --git a/README.md b/README.md index d9bb2b0..63a95d0 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@

- Stop paying twice for work Codex already did.
- Carry completed work across independent Codex chats—locally, without special prompts. + AI memory. Fewer tokens.
+ Reuse prior work across independent AI agent chats, with local memory and no LLM calls to organize it.

@@ -28,26 +28,17 @@ Privacy

-

- - - - Recorded LLVM walkthrough: investigate in one chat, reuse findings in another, check new questions, and compare tokens and answer quality. All attempts used 82% fewer follow-up tokens per completion; six answers were usable, three needed minor corrections, and one needed a substantive correction. One task and one fresh reference, with prior work excluded. - - -

- -

- Read the recorded walkthrough · - Watch the 30-second video · - Static version -

- -Eggshell is a local memory plugin for Codex. It saves work from one chat and -makes relevant results available to a separate chat: repository searches, -commands, documentation findings, and the conclusions drawn from them. +Eggshell is **local memory that helps AI agents use fewer tokens**. +It saves work from one chat and makes relevant results available to a separate +chat: repository searches, commands, documentation findings, and conclusions. It is useful when you return to related work in the same project. +Use the published **Codex plugin**, or the separate, experimental +[adapters for Claude Code, Gemini CLI, Cursor, and OpenCode](adapters/README.md). +The adapters use the same memory engine and are built and installed separately. +They have automated engine integration tests; live agent sessions and token +savings have not yet been evaluated for those four clients. + In the [recorded LLVM walkthrough](docs/demo.md), one chat maps how Clang chooses a toolchain. A new chat reuses those findings to investigate language and target edge cases, and reports what remains unverified. You ask ordinary questions; @@ -62,15 +53,30 @@ prior work; see [Evidence](#evidence) for the comparison and its limits. ## Install +For **Claude Code, Gemini CLI, Cursor, or OpenCode**, follow the +[adapter installation guide](adapters/README.md#install). The steps below install +the **Codex plugin**. + You need macOS or Linux on Apple Silicon/ARM64 or x86-64, Python 3, and the Codex CLI available as `codex`. Your Codex client must support plugins and command hooks. Setup downloads the Eggshell binary and a local search model. -**[Install from the Plugins Directory](https://chatgpt.com/plugins/plugins_6aa482a5d9048191a727260b5f898078)**, -then ask Codex: **“Set up Eggshell for this Codex project.”** The included setup -workflow installs the runtime for the directory plugin. +1. **[Install the plugin](https://chatgpt.com/plugins/plugins_6aa482a5d9048191a727260b5f898078)**, + then ask Codex: **“Set up Eggshell for this project.”** Setup installs the + runtime and search model and prepares the project, preserving existing settings. +2. **Enable it in `/hooks`**, then start a new chat in the project. +3. **Check the startup message:** “Eggshell session hook connected”. Run + **`!egg doctor`** to check setup. If the message is absent, check `/hooks`. + Complete the [two-chat example](docs/try-it.md) and use **`!egg graph`** to + confirm that saved work reaches the follow-up. -For a standalone installation from a terminal: +After initial setup, recording and relevant handoffs are automatic; ordinary +tasks need no special prompts. Missing setup produces a startup notice once the +hooks are trusted. **This integration requires Codex command hooks; ordinary +ChatGPT Chat does not provide automatic Eggshell memory.** + +
+Install from a terminal instead ```sh curl --proto '=https' --tlsv1.2 -fsSL \ @@ -85,6 +91,8 @@ command, and prepares local search. Add the same PATH setting to your shell configuration if needed. In Codex, review and enable Eggshell's hooks through `/hooks`, then start a new chat in the project. +
+ `egg init` creates `.eggshell.toml` and configures `.eggs/work.egg`, a local file of saved work and outcomes. The `.eggs` directory is ignored by Git. The work file appears when the first turn is saved. @@ -121,18 +129,14 @@ For shared work files, custom install locations, and troubleshooting, see the ## How it works -

- Codex work and outcomes are saved locally; relevant prior work is selected for a separate chat -

- -1. **Record work and outcomes.** Codex hooks observe the current request, +1. **Record work and outcomes.** The integration observes the current request, supported tool inputs and results, and the final answer. A timeout or empty result can be useful evidence too. 2. **Select relevant history.** Local text matching and MiniLM embeddings find related work in the files you allow Eggshell to read. The graph connects requests to outcomes and their supporting operations. 3. **Continue the task.** Eggshell sends selected prior work as a **handoff**: - context for the new chat. Codex is asked to reuse supported findings, check + context for the new chat. The agent is asked to reuse supported findings, check open or changed facts, and report what it reused, checked, or left unverified. 4. **Save progress.** Each observed tool result is saved independently. The final answer adds the parent task result; unfinished work remains open. @@ -143,7 +147,7 @@ Eggshell preserves the earlier outcome so the agent can explain what changed. Search and graph processing run locally. Eggshell does not ask an LLM to write summaries, classify memories, or maintain the graph. Selected memory and the -agent's subsequent work still consume the normal Codex input and output tokens. +agent's subsequent work still consume the model's normal input and output tokens. See the [architecture reference](docs/architecture.md) for matching, graph operations, and the Lean core. @@ -159,6 +163,7 @@ Run these inside the relevant Codex chat: !egg graph show the exact handoff sent to Codex !egg why explain the handoff selection !egg inspect show resolved storage paths +!egg doctor check setup without changing settings or memory !egg off disable memory and clear the active turn (saved work is retained) !egg on enable memory again !egg next private read memory without saving the next turn @@ -174,8 +179,8 @@ recording and handoffs. [More controls and configuration](docs/codex-plugin.md). ### One LLVM follow-up task, ten completed trials -We repeated one investigation of Clang target and language options that affect -toolchain selection or forwarded arguments. Each trial started in an independent +Using Codex, we repeated one investigation of Clang target and language options +that affect toolchain selection or forwarded arguments. Each trial started in an independent chat with the same question, source snapshot, model, and prior `.egg`. These trials used the **current default handoff prompt**. It directs the agent to reuse supported results, check unresolved or changed @@ -229,13 +234,13 @@ or superiority over other memory methods. Eggshell has no hosted service, telemetry, or account system. Saved work, embeddings, and search processing stay on your machine. **Selected prior work -is passed to Codex as model input** and is handled under the settings and terms -of your Codex provider, just like other context in the chat. +is passed to your agent as model input** and is handled under the settings and +terms of its model provider, just like other context in the chat. Installation downloads the release, Python dependencies, and MiniLM model. Work files may contain prompts, source code, and tool results; choose carefully which files a project can read. Read-only mode prevents saving new work but -does not prevent sending existing memory to Codex. +does not prevent sending existing memory to the agent. See [PRIVACY.md](PRIVACY.md) for storage locations, network behavior, and removal. Report vulnerabilities through the private channel in diff --git a/adapters/.gitignore b/adapters/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/adapters/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/adapters/README.md b/adapters/README.md new file mode 100644 index 0000000..2d0d036 --- /dev/null +++ b/adapters/README.md @@ -0,0 +1,174 @@ +# Eggshell adapters + +Use the same local memory engine from Claude Code, Gemini CLI, Cursor, or +OpenCode. These adapters are a separate package: the existing Eggshell engine, +retriever, handoff prompt, and Codex plugin do not depend on them. + +**Status:** experimental, source-built adapters with automated tests against the +real memory engine. Live sessions in these four agents and their token savings +have not yet been evaluated. The published Codex experiment does not establish +a reduction rate for these adapters. + +| Agent | Integration | When selected memory can reach the agent | +| --- | --- | --- | +| Claude Code | Project command hooks | Before a prompt, before a tool, or after a tool result | +| Gemini CLI | Project command hooks | Before a prompt or after a tool result; a covered operation can be denied before execution | +| Cursor | Project command hooks | After a tool result; a covered operation can be denied before execution | +| OpenCode | Project JavaScript plugin | A separate context part on a user message, or after a tool result; a covered operation can be denied before execution | + +Cursor's prompt hook cannot inject context. Its adapter stages the request but +does not mark that hook's retrieved memory as delivered. Later tool hooks can +deliver it. This difference is part of the integration contract. + +## Install + +You need macOS or Linux, Python 3.9 or later, the project's pinned Lean +toolchain, and an agent version supporting the events listed below. OpenCode +uses its own JavaScript runtime; the plugin has no npm dependencies. + +From an Eggshell source checkout, install the local runtime and search model, +then build the separate adapter companion: + +```sh +lake build eggshell +.lake/build/bin/eggshell install runtime +export PATH="${EGGSHELL_PREFIX:-$HOME/.local}/bin:$PATH" +(cd adapters/native && lake build) +``` + +In a project without existing Eggshell project, parent, or global configuration, +run `egg init` from that project's directory. Existing configuration and memory +profiles continue to apply. + +Back in the source checkout, choose **one** adapter: + +```sh +python3 adapters/install.py claude --project /absolute/path/to/project +python3 adapters/install.py gemini --project /absolute/path/to/project +python3 adapters/install.py cursor --project /absolute/path/to/project +python3 adapters/install.py opencode --project /absolute/path/to/project +``` + +The installer copies the companion and adapters into +`${EGGSHELL_PREFIX:-$HOME/.local}/share/eggshell-adapters` and adds only the chosen +project integration. `--prefix` selects another runtime prefix. Existing hooks +and other settings are preserved; repeat installation does not duplicate hooks. +Invalid configuration and unowned plugin files are rejected without replacement. +The installer does not enable globally disabled hooks or approve project trust. + +Review the new hooks or plugin in your agent, then restart the project chat. +Installation alone does not demonstrate that memory is being saved or delivered. + +| Agent | Project file | +| --- | --- | +| Claude Code | `.claude/settings.json` | +| Gemini CLI | `.gemini/settings.json` | +| Cursor | `.cursor/hooks.json` | +| OpenCode | `.opencode/plugins/eggshell.js` | + +These are local command/plugin integrations. Ordinary ChatGPT Chat and agent +environments without access to the local companion are not covered. + +## Verify two-chat reuse + +Use the repository and questions in the [two-chat example](../docs/try-it.md), +running both chats in your selected agent. Let the first chat perform a real +investigation, then ask a related question in a new chat in the same project. +The first chat's tool results should reach `.eggs/work.egg` before it finishes. + +For inspection, supply the native session ID from the agent's hook/debug output: + +```sh +python3 "$HOME/.local/share/eggshell-adapters/eggshell_adapter.py" \ + control claude --session NATIVE_SESSION_ID doctor +python3 "$HOME/.local/share/eggshell-adapters/eggshell_adapter.py" \ + control claude --session NATIVE_SESSION_ID graph +``` + +Run these from the project directory. Replace `claude` with your chosen adapter, +and adjust the path for a custom prefix. `doctor` checks local configuration; +`graph` shows the handoff whose delivery was acknowledged. The same entrypoint +accepts the existing `off`, `on`, `why`, `inspect`, and other engine controls. +It launches no model turn. + +## Remove an adapter + +From the source checkout: + +```sh +python3 adapters/install.py claude --project /absolute/path/to/project --uninstall +``` + +Only the exact entries installed by this adapter are removed. Modified or +unrelated entries remain. The runtime, other adapters, configuration, and saved +memory are preserved. + +## Boundaries and guarantees + +- **One engine manager per chat.** The adapter namespaces native session IDs by + harness. Its small SQLite database records opaque turn/call identifiers; + transactions finish before any engine or search call. It starts no additional + adapter manager and holds no cross-chat lock during normal operation. +- **The engine owns memory.** Tool names, inputs, and results are passed through + to the existing engine. Its journal captures results before manager RPC or + search. Search and saving retain their existing independent workers. + The companion retains the engine's authorized turn snapshot so a late tool + result can be queued under its original task after that task has been sealed. +- **Delivery requires a supported output.** Unsupported context fields are + discarded without acknowledgment. Command adapters acknowledge after writing + and flushing the host response. OpenCode acknowledges after inserting the + context into its output object. The engine rejects receipts from an obsolete + turn or compaction epoch. This confirms delivery at the integration boundary, + not that a model used the evidence correctly. +- **End-of-turn hooks cannot request another iteration.** The output translator + returns `{}` for every `Stop`, `Interrupt`, and `SessionEnd` input, independent + of the engine reply. The OpenCode plugin does not create follow-up prompts or + replace the agent's compaction prompt. +- **Failures preserve ordinary agent execution.** Adapter errors emit an empty + hook response, with diagnostics on stderr. The companion uses the engine's + existing bounded transport; captured tool receipts stay available to its + writer. A covered operation can still receive the engine's normal denial and + reuse instructions. Permission approvals are never granted by the adapter. + +Gemini CLI does not provide native tool call IDs. The adapter correlates its +before/after events by exact tool name and arguments, preserves separate +occurrences, and uses the hook timestamp to recognize replayed events. If the +same operation overlaps different turns, or another hook changes its arguments, +the originating task may be ambiguous. When the possible originating operations +were writable and memory still permits writing, the result +is retained under the adapter's private `unattributed` directory instead of +being assigned to another task or promoted into the graph. + +Cursor emits assistant text separately from loop completion. The adapter holds +a private temporary candidate only when memory is writable, and saves it as a +final answer only when the loop reports completion. An abort is not a successful +final outcome. OpenCode similarly waits for an idle session and a completed +assistant message; if final text was not observed, it preserves tool progress +without inventing an answer. + +## Development and contract tests + +```sh +(cd adapters/native && lake build) +python3 tests/test_adapters.py -v +node --test tests/test_opencode_adapter.mjs +``` + +Tests exercise actual engine processes, saving before turn completion, reuse in +a separate chat, concurrent tool results, off mode, compaction receipts, output +translation, and installation ownership. They make no LLM or network calls. +The OpenCode output-object tests separately verify insertion-before-ack order. + +The dependency is one-way: `adapters/native` imports the engine as a local Lake +dependency. `adapters/eggshell_adapter.py` owns host JSON translation and +identifier correlation; `adapters/opencode.mjs` owns OpenCode plugin callbacks. +The companion translates neither host tools nor retrieval results. No adapter +code is linked into the existing `eggshell` executable or Codex plugin. + +Reference contracts checked on 2026-09-12: +[Claude Code hooks](https://code.claude.com/docs/en/hooks), +[Gemini CLI hooks](https://geminicli.com/docs/hooks/reference/), +[Cursor hooks](https://cursor.com/docs/hooks), and +[OpenCode plugins](https://opencode.ai/docs/plugins/). +OpenCode callbacks were also checked against `@opencode-ai/plugin` 1.18.30's +published type declarations. Event availability may differ in older clients. diff --git a/adapters/eggshell_adapter.py b/adapters/eggshell_adapter.py new file mode 100644 index 0000000..f06d6a1 --- /dev/null +++ b/adapters/eggshell_adapter.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Host adapters for Eggshell's separate bridge; no retrieval implementation. + +Only opaque turn/call identifiers live in the adapter database. Tool bodies are +journaled by the engine before RPC/search. All locks end before a process call. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import signal +import sqlite3 +import subprocess +import sys +import uuid + +CLIENTS = ('claude', 'gemini', 'cursor', 'opencode') +EVENTS = { + 'claude': dict(SessionStart='SessionStart', UserPromptSubmit='UserPromptSubmit', + PreToolUse='PreToolUse', PostToolUse='PostToolUse', + PostToolUseFailure='PostToolUse', Stop='Stop', + StopFailure='Interrupt', SessionEnd='SessionEnd'), + 'gemini': dict(SessionStart='SessionStart', BeforeAgent='UserPromptSubmit', + BeforeTool='PreToolUse', AfterTool='PostToolUse', + AfterAgent='Stop', PreCompress='PostCompact', SessionEnd='SessionEnd'), + 'cursor': dict(sessionStart='SessionStart', beforeSubmitPrompt='UserPromptSubmit', + preToolUse='PreToolUse', postToolUse='PostToolUse', + postToolUseFailure='PostToolUse', afterAgentResponse='AssistantMessage', + stop='Stop', preCompact='PostCompact', sessionEnd='SessionEnd'), + 'opencode': {name: name for name in ('SessionStart', 'UserPromptSubmit', + 'PreToolUse', 'PostToolUse', 'PostCompact', 'Stop', 'Interrupt', 'SessionEnd')}, +} +CONTEXT_EVENTS = { + 'claude': ('UserPromptSubmit', 'PreToolUse', 'PostToolUse'), + 'gemini': ('UserPromptSubmit', 'PostToolUse'), + 'cursor': ('PostToolUse',), + 'opencode': ('UserPromptSubmit', 'PostToolUse'), +} + + +def encode(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + + +def session_key(client, native): + if client not in CLIENTS or not isinstance(native, str) or not native: + raise ValueError('hook must provide a nonempty native session ID') + return client + '-' + hashlib.sha256(native.encode()).hexdigest() + + +def data_root(): + prefix = Path(os.environ.get('EGGSHELL_PREFIX', str(Path.home() / '.local'))) + root = Path(os.environ.get('EGGSHELL_DATA_ROOT', str(prefix / 'share/eggshell/plugin'))) + if not root.is_absolute(): + raise ValueError('EGGSHELL_DATA_ROOT must be absolute') + return root / 'adapters' + + +def require_text(raw, key): + value = raw.get(key) + if not isinstance(value, str) or not value: + raise ValueError('hook must provide ' + key) + return value + + +class UnattributedResult(ValueError): + def __init__(self, event, recordable=False): + super().__init__('tool result has no unambiguous originating turn; not attached to another task') + self.event = dict(event) + self.event.pop('turn_id', None) + self.recordable = recordable + + +class Correlation: + """A short per-chat transaction, never a search lock or a second manager.""" + def __init__(self, root, session): + root.mkdir(parents=True, exist_ok=True, mode=0o700) + root.chmod(0o700) + path = root / (session + '.sqlite3') + self.db = sqlite3.connect(path, timeout=.1) + path.chmod(0o600) + self.db.execute('PRAGMA secure_delete=ON') + self.db.executescript(''' + CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS calls ( + id TEXT PRIMARY KEY, native TEXT NOT NULL, turn TEXT NOT NULL, + finished INTEGER NOT NULL DEFAULT 0, writable INTEGER NOT NULL DEFAULT 0); + CREATE INDEX IF NOT EXISTS calls_native ON calls(native, finished); + CREATE TABLE IF NOT EXISTS receipts (stamp TEXT PRIMARY KEY, id TEXT NOT NULL, turn TEXT NOT NULL); + ''') + + def __enter__(self): + self.db.execute('BEGIN IMMEDIATE') + return self + + def __exit__(self, error_type, error, traceback): + if error_type: + self.db.rollback() + else: + self.db.commit() + self.db.close() + + def get(self, key): + row = self.db.execute('SELECT value FROM state WHERE key=?', (key,)).fetchone() + return row[0] if row else None + + def put(self, key, value): + self.db.execute('INSERT OR REPLACE INTO state VALUES (?,?)', (key, value)) + + def bind(self, native, turn, explicit): + call = explicit or uuid.uuid4().hex + existing = self.db.execute('SELECT turn FROM calls WHERE id=?', (call,)).fetchone() + if existing and existing[0] != turn: + raise ValueError('native tool ID was reused across different turns') + self.db.execute('INSERT OR IGNORE INTO calls(id,native,turn) VALUES (?,?,?)', + (call, native, turn)) + return call + + def terminal(self, native, explicit, turn): + if explicit: + rows = self.db.execute('SELECT id,turn FROM calls WHERE id=?', (explicit,)).fetchall() + else: + rows = self.db.execute('SELECT id,turn FROM calls WHERE native=? AND finished=0 ' + 'ORDER BY rowid', (native,)).fetchall() + if turn: + rows = [row for row in rows if row[1] == turn] + if not rows and explicit and turn: + return self.bind(native, turn, explicit), turn + if not rows or len({row[1] for row in rows}) != 1: + raise ValueError('tool result has no unambiguous originating turn') + call, original_turn = rows[0] + self.db.execute('UPDATE calls SET finished=1 WHERE id=?', (call,)) + return call, original_turn + + +def normalize(client, raw, root): + if not isinstance(raw, dict): + raise ValueError('hook input must be an object') + native_event = raw.get('hook_event_name') + event = EVENTS[client].get(native_event) + if event is None: + return None + native = require_text(raw, 'conversation_id' if client == 'cursor' else 'session_id') + session = session_key(client, native) + cwd = raw.get('cwd') + if not cwd: + roots = raw.get('workspace_roots', []) + if not isinstance(roots, list) or len(roots) != 1: + raise ValueError('hook must identify one project working directory') + cwd = roots[0] + if not isinstance(cwd, str) or not Path(cwd).is_absolute(): + raise ValueError('hook working directory must be absolute') + result = dict(session_id=session, cwd=cwd, hook_event_name=event) + # Do not forward account details, transcripts, reasoning, or internal fields. + for key in ('source', 'prompt', 'tool_name', 'tool_input', 'tool_response'): + if key in raw: + result[key] = raw[key] + explicit_turn = raw.get('generation_id' if client == 'cursor' else 'turn_id') + if explicit_turn is not None and (not isinstance(explicit_turn, str) or not explicit_turn): + raise ValueError('invalid native turn ID') + with Correlation(root, session) as state: + if event == 'UserPromptSubmit': + require_text(raw, 'prompt') + turn = explicit_turn or uuid.uuid4().hex + state.put('turn', turn) + result['turn_id'] = turn + return result + turn = explicit_turn or state.get('turn') + if turn: + result['turn_id'] = turn + if event in ('PreToolUse', 'PostToolUse'): + name = require_text(raw, 'tool_name') + if 'tool_input' not in raw: + raise ValueError('missing tool_input') + call = raw.get('tool_use_id') + if call is not None and (not isinstance(call, str) or not call): + raise ValueError('invalid tool call ID') + native_call = call or hashlib.sha256(encode([name, raw['tool_input']]).encode()).hexdigest() + stamp = raw.get('timestamp') + stamp_key = hashlib.sha256(encode([native_event, native_call, stamp]).encode()).hexdigest() \ + if isinstance(stamp, str) and stamp else None + if event == 'PreToolUse': + if not turn: + raise ValueError('tool arrived before a user turn') + result['tool_use_id'] = state.bind(native_call, turn, call or stamp_key) + else: + if native_event in ('PostToolUseFailure', 'postToolUseFailure'): + result['tool_response'] = dict(error=raw.get('error'), is_error=True) + elif client == 'cursor': + response = raw.get('tool_output') + if isinstance(response, str): + try: + response = json.loads(response) + except ValueError: + pass + result['tool_response'] = response + elif 'tool_response' not in raw: + raise ValueError('missing tool_response') + previous = state.db.execute('SELECT id,turn FROM receipts WHERE stamp=?', + (stamp_key,)).fetchone() if stamp_key else None + try: + call, turn = previous or state.terminal(native_call, call, explicit_turn) + except ValueError: + permissions = state.db.execute('SELECT writable FROM calls WHERE native=?', + (native_call,)).fetchall() + raise UnattributedResult(result, bool(permissions) and + all(row[0] for row in permissions)) from None + result.update(tool_use_id=call, turn_id=turn) + if stamp_key: + state.db.execute('INSERT OR IGNORE INTO receipts VALUES (?,?,?)', (stamp_key, call, turn)) + if event == 'Stop': + field = 'prompt_response' if client == 'gemini' else 'last_assistant_message' + final = raw.get(field) + if isinstance(final, str): + result['last_assistant_message'] = final + return result + + +def translate(client, event, output): + """Return only fields accepted by the host for this event. + + End-of-turn hooks always return {}, regardless of the engine reply. + Unsupported advisory output is never acknowledged as delivered. + """ + if event not in ('UserPromptSubmit', 'PreToolUse', 'PostToolUse'): + return {} + specific = output.get('hookSpecificOutput', {}) + if not isinstance(specific, dict): + return {} + if event == 'PreToolUse' and specific.get('permissionDecision') == 'deny': + reason = specific.get('permissionDecisionReason', '') + if not isinstance(reason, str) or not reason: + return {} + if client in ('claude', 'opencode'): + return {'hookSpecificOutput': dict(hookEventName='PreToolUse', + permissionDecision='deny', permissionDecisionReason=reason)} + if client == 'gemini': + return dict(decision='deny', reason=reason) + return dict(permission='deny', agent_message=reason) + context = specific.get('additionalContext', '') + if event not in CONTEXT_EVENTS[client] or not isinstance(context, str) or not context: + return {} + if client == 'cursor': + return dict(additional_context=context) + target_event = ('BeforeAgent' if event == 'UserPromptSubmit' else 'AfterTool') \ + if client == 'gemini' else event + return {'hookSpecificOutput': dict(hookEventName=target_event, additionalContext=context)} + + +def invoke(bridge, command, payload, cwd, session=None): + env = dict(os.environ) + # An installed Codex plugin must not redirect this companion's manager. + env.pop('PLUGIN_ROOT', None) + env.pop('CODEX_THREAD_ID', None) + if session: + env['CODEX_THREAD_ID'] = session + child = subprocess.Popen([str(bridge), *command], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env, + start_new_session=True, text=True) + try: + stdout, stderr = child.communicate(encode(payload) if payload is not None else '', timeout=30) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.communicate() + raise RuntimeError('adapter bridge timed out; the engine retains captured tool results') + if stderr: + print(stderr.rstrip(), file=sys.stderr) + if child.returncode: + raise RuntimeError('adapter bridge exited with code ' + str(child.returncode)) + return json.loads(stdout) if stdout.strip() else {} + + +def is_denied(reply): + return (reply.get('permission') == 'deny' or reply.get('decision') == 'deny' or + reply.get('hookSpecificOutput', {}).get('permissionDecision') == 'deny') + + +def record_call_status(root, event, reply, writable): + if event['hook_event_name'] != 'PreToolUse': + return + with Correlation(root, event['session_id']) as state: + state.db.execute('UPDATE calls SET finished=?, writable=? WHERE id=?', + (int(is_denied(reply)), int(writable), event['tool_use_id'])) + + +def draft_response(bridge, event, raw): + """Cursor reports answer text separately from loop completion. + + Keep a temporary candidate only while memory is writable. Never turn an + intermediate assistant message or an aborted loop into a final outcome. + """ + turn = event.get('turn_id') + if not turn: + return + if event['hook_event_name'] == 'AssistantMessage': + if isinstance(raw.get('text'), str): + invoke(bridge, ['draft'], dict(event, text=raw['text']), event['cwd']) + elif event['hook_event_name'] == 'Stop': + event['_adapter_use_draft'] = raw.get('status') == 'completed' + + +def run_hook(client, raw, bridge, root): + event = normalize(client, raw, root) + if event is None: + print('{}', flush=True) + return + if client == 'cursor': + draft_response(bridge, event, raw) + if event['hook_event_name'] == 'AssistantMessage': + print('{}', flush=True) + return + receipt = invoke(bridge, ['deliver'], event, event['cwd']) + if not receipt.get('ok'): + print('{}', flush=True) + return + reply = translate(client, event['hook_event_name'], receipt['output']) + record_call_status(root, event, reply, receipt.get('writable', False)) + if client == 'opencode': + # The JS plugin confirms only after mutating the host's output object. + print(encode(dict(output=reply, receipt=receipt['receipt'], + session_id=event['session_id'])), flush=True) + else: + print(encode(reply), flush=True) + if reply: + try: + invoke(bridge, ['ack'], dict(session_id=event['session_id'], + receipt=receipt['receipt']), event['cwd']) + except (OSError, ValueError, RuntimeError) as error: + # stdout already contains the complete hook response. + print('Eggshell delivery receipt: ' + str(error), file=sys.stderr) + + +def retain_unattributed(bridge, root, event): + # Keep ambiguous evidence private, only when this chat permits writing. + report = invoke(bridge, ['egg', 'doctor'], None, event['cwd'], event['session_id']) + if report.get('memory') != 'read/write': + return + directory = root / 'unattributed' / event['session_id'] + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + path = directory / (uuid.uuid4().hex + '.json') + with path.open('x') as stream: + os.chmod(path, 0o600) + stream.write(encode(event)) + stream.flush() + os.fsync(stream.fileno()) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=('hook', 'ack', 'control')) + parser.add_argument('client', choices=CLIENTS) + parser.add_argument('--bridge', type=Path, + default=Path(__file__).resolve().with_name('eggshell-bridge')) + parser.add_argument('--session') + parser.add_argument('commands', nargs='*') + args = parser.parse_intermixed_args() + if args.mode == 'control': + if not args.session: + parser.error('control requires --session with the native chat ID') + session = session_key(args.client, args.session) + env = dict(os.environ, CODEX_THREAD_ID=session) + env.pop('PLUGIN_ROOT', None) + if args.commands == ['doctor']: + report = invoke(args.bridge, ['egg', 'doctor'], None, os.getcwd(), session) + report['hook_trust'] = 'Review hooks in ' + args.client + report['next_step'] = 'Restart the agent and verify saving and delivery with the two-chat example.' + print(json.dumps(report, indent=2)) + return 0 + return subprocess.call([str(args.bridge), 'egg', *args.commands], env=env) + try: + root = data_root() + raw = json.load(sys.stdin) + if args.mode == 'ack': + if not require_text(raw, 'session_id').startswith(args.client + '-'): + raise ValueError('receipt belongs to another harness') + invoke(args.bridge, ['ack'], raw, os.getcwd()) + else: + run_hook(args.client, raw, args.bridge, root) + return 0 + except (OSError, ValueError, RuntimeError, sqlite3.Error) as error: + if isinstance(error, UnattributedResult) and error.recordable: + try: + retain_unattributed(args.bridge, root, error.event) + except (OSError, ValueError, RuntimeError) as retention_error: + print('Eggshell could not retain ambiguous evidence: ' + str(retention_error), file=sys.stderr) + print('Eggshell adapter: ' + str(error), file=sys.stderr) + if args.mode == 'hook': + print('{}', flush=True) + return 0 + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/adapters/install.py b/adapters/install.py new file mode 100644 index 0000000..d78da38 --- /dev/null +++ b/adapters/install.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Install one project adapter without changing the Eggshell or Codex package.""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile + +from eggshell_adapter import CLIENTS, EVENTS + +SOURCE = Path(__file__).resolve().parent +OWNER = 'momonpya/eggshell-adapters-v1\n' +CONFIGS = dict(claude='.claude/settings.json', gemini='.gemini/settings.json', + cursor='.cursor/hooks.json', opencode='.opencode/plugins/eggshell.js') + + +def atomic_write(path, contents, mode=0o600): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=path.name + '.', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(contents) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, mode) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def write_json(path, value): + mode = path.stat().st_mode & 0o777 if path.exists() else 0o600 + atomic_write(path, (json.dumps(value, indent=2, ensure_ascii=False) + '\n').encode(), mode) + + +def read_document(path): + if not path.exists(): + return {} + value = json.loads(path.read_text()) + if not isinstance(value, dict): + raise ValueError(str(path) + ' must contain a JSON object') + return value + + +def remove_entries(document, entries, client): + hooks = document.get('hooks', {}) + if not isinstance(hooks, dict): + raise ValueError('existing hooks must be an object') + for event, owned in entries.items(): + current = hooks.get(event, []) + if not isinstance(current, list): + raise ValueError('existing hook event must contain an array: ' + event) + kept = [entry for entry in current if entry != owned] + if kept: + hooks[event] = kept + else: + hooks.pop(event, None) + if hooks: + document['hooks'] = hooks + else: + document.pop('hooks', None) + return document + + +def entries_for(client, command): + entries = {} + for event in EVENTS[client]: + # One common upper bound surrounds the engine's own bounded delivery. + # Stop hooks do not request retries, and errors use the host's fail-open default. + handler = dict(type='command', command=command, timeout=35000 if client == 'gemini' else 35) + if client == 'gemini': + handler['name'] = 'eggshell-' + event + entries[event] = handler if client == 'cursor' else dict(hooks=[handler]) + return entries + + +def install(client, project, prefix, bridge, uninstall=False): + project, prefix, bridge = project.resolve(), prefix.resolve(), bridge.resolve() + if not project.is_dir(): + raise ValueError('project directory does not exist') + support = prefix / 'share/eggshell-adapters' + marker = support / '.owner' + if support.exists() and (not marker.exists() or marker.read_text() != OWNER): + raise ValueError('refusing to replace an unowned adapter directory') + config = project / CONFIGS[client] + key = hashlib.sha256((client + '\n' + str(config)).encode()).hexdigest() + receipt_path = support / 'receipts' / (key + '.json') + previous = read_document(receipt_path) + if client == 'opencode': + old = config.read_text() if config.exists() else None + if old is not None and old != previous.get('contents'): + raise ValueError('refusing to replace an unowned OpenCode plugin') + document = None + else: + document = read_document(config) + if client == 'cursor' and document.get('version', 1) != 1: + raise ValueError('unsupported Cursor hooks schema version') + remove_entries(document, previous.get('entries', {}), client) + if uninstall: + if not previous: + return dict(status='not-installed', client=client, config=str(config)) + if client == 'opencode': + config.unlink(missing_ok=True) + else: + write_json(config, document) + receipt_path.unlink() + return dict(status='removed', client=client, config=str(config), + memory='preserved', runtime='preserved') + checked = subprocess.run([bridge, '--help'], capture_output=True, text=True, check=True) + if 'Eggshell adapter bridge:' not in checked.stdout: + raise ValueError('not an Eggshell adapter bridge executable') + support.mkdir(parents=True, exist_ok=True, mode=0o700) + atomic_write(marker, OWNER.encode()) + installed_bridge = support / 'eggshell-bridge' + if bridge != installed_bridge: + atomic_write(installed_bridge, bridge.read_bytes(), 0o755) + for name in ('eggshell_adapter.py', 'opencode.mjs'): + atomic_write(support / name, (SOURCE / name).read_bytes(), 0o644) + command = shlex.join(['env', 'EGGSHELL_PREFIX=' + str(prefix), 'python3', + str(support / 'eggshell_adapter.py'), 'hook', client]) + if client == 'opencode': + contents = ('// Eggshell project adapter. Remove with adapters/install.py --uninstall.\n' + 'export { Eggshell } from ' + json.dumps((support / 'opencode.mjs').as_uri()) + ';\n') + receipt = dict(client=client, config=str(config), contents=contents) + # Store ownership before publishing the project entrypoint. + write_json(receipt_path, receipt) + atomic_write(config, contents.encode(), 0o644) + else: + entries = entries_for(client, command) + hooks = document.setdefault('hooks', {}) + if not isinstance(hooks, dict): + raise ValueError('existing hooks must be an object') + for event, entry in entries.items(): + current = hooks.setdefault(event, []) + if not isinstance(current, list): + raise ValueError('existing hook event must contain an array: ' + event) + current.append(entry) + if client == 'cursor': + document.setdefault('version', 1) + write_json(receipt_path, dict(client=client, config=str(config), entries=entries)) + write_json(config, document) + return dict(status='configured', client=client, config=str(config), + next_step='Review the hooks in your agent, restart the project chat, then verify two-chat reuse.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('client', choices=CLIENTS) + parser.add_argument('--project', type=Path, default=Path.cwd()) + parser.add_argument('--prefix', type=Path, default=Path(os.environ.get( + 'EGGSHELL_PREFIX', str(Path.home() / '.local')))) + parser.add_argument('--bridge', type=Path, + default=SOURCE / 'native/.lake/build/bin/eggshell_bridge') + parser.add_argument('--uninstall', action='store_true') + args = parser.parse_args() + # Installation owns a separate lock; hook execution never takes it. + args.prefix.mkdir(parents=True, exist_ok=True) + with (args.prefix / '.eggshell-adapter-install.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + result = install(args.client, args.project, args.prefix, args.bridge, args.uninstall) + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/adapters/native/.gitignore b/adapters/native/.gitignore new file mode 100644 index 0000000..01f8cdb --- /dev/null +++ b/adapters/native/.gitignore @@ -0,0 +1 @@ +.lake/ diff --git a/adapters/native/Main.lean b/adapters/native/Main.lean new file mode 100644 index 0000000..653f709 --- /dev/null +++ b/adapters/native/Main.lean @@ -0,0 +1,142 @@ +module + +public import Eggshell.Daemon + +@[expose] public section + +open Lean Eggshell Eggshell.Plugin + +def draftPath (files : SessionFiles) (turn : String) : System.FilePath := + files.directory / "adapter-drafts" / + (Blake3.hex (Blake3.digest "eggshell.turn".toUTF8 [turn.toUTF8]) ++ ".json") + +def turnPath (files : SessionFiles) (turn : String) : System.FilePath := + files.directory / "adapter-turns" / + (Blake3.hex (Blake3.digest "eggshell.turn".toUTF8 [turn.toUTF8]) ++ ".json") + +/-- Retain the engine's original write authorization for late terminal hooks. + This is an immutable engine value, not a second graph or search policy. -/ +def rememberTurn (input : Json) : IO Unit := do + if optionalString input "hook_event_name" != some "UserPromptSubmit" then return + let session ← IO.ofExcept (requiredString input "session_id") + let turn ← IO.ofExcept (requiredString input "turn_id") + withSession session fun files => do + let some state ← readState? files | return + let some pending ← readPendingBase? files | return + if !state.enabled || pending.turnId != turn || pending.write.isNone then return + writeJson (turnPath files turn) { pending with tools := [], inFlight := [] } + +/-- A tool started under an earlier turn keeps that turn's write target even + when its terminal hook arrives after the engine has sealed/deferred it. -/ +def captureLateResult (input : Json) : IO Unit := do + if optionalString input "hook_event_name" != some "PostToolUse" then return + let session ← IO.ofExcept (requiredString input "session_id") + let turn ← IO.ofExcept (requiredString input "turn_id") + withSession session fun files => do + let some state ← readState? files | return + if !state.enabled then return + let current ← readPendingBase? files + if current.any (fun pending => pending.turnId == turn && !pending.closed && pending.finalMessage.isNone) then return + let some original ← (readJson? (turnPath files turn) : IO (Option PendingTurn)) | return + let tool ← IO.ofExcept (toolFromHook input true) + queueCheckpoint files { + original with tools := [tool], inFlight := [], finalMessage := none, closed := false } + +/-- Some hosts report answer text before loop completion. A candidate is local + adapter state, never a final engine outcome. Respect the selected turn's + write profile, including a one-turn read-only override. -/ +def draftAnswer : IO UInt32 := do + try + let input ← IO.ofExcept (Json.parse (← (← IO.getStdin).readToEnd)) + let session ← IO.ofExcept (requiredString input "session_id") + let turn ← IO.ofExcept (requiredString input "turn_id") + withSession session fun files => do + let some state ← readState? files | return + let some pending ← readPendingBase? files | return + if !state.enabled || pending.turnId != turn || pending.closed || pending.write.isNone then return + let text ← IO.ofExcept (requiredString input "text") + writeJson (draftPath files turn) text + IO.println "{}" + pure 0 + catch error => + IO.eprintln s!"Eggshell adapter draft: {error}" + IO.println "{}" + pure 0 + +def attachDraft (input : Json) : IO Json := do + if optionalString input "hook_event_name" != some "Stop" then return input + let session ← IO.ofExcept (requiredString input "session_id") + let some turn := optionalString input "turn_id" | return input + withSession session fun files => do + let path := draftPath files turn + let state ← readState? files + let pending ← readPendingBase? files + let permitted := state.any (·.enabled) && pending.any fun pending => + pending.turnId == turn && pending.write.isSome && !pending.closed + let text : Option String ← if permitted && input.getObjValD "_adapter_use_draft" == true then + readJson? path + else pure none + removeIfExists path + pure (text.map (fun text => input.setObjVal! "last_assistant_message" (toJson text)) |>.getD input) + +/-- A separate companion executable. The memory engine and its Codex entrypoint + are imported unchanged; host-specific schemas live outside this package. -/ +def deliver : IO UInt32 := do + try + let raw ← IO.ofExcept (Json.parse (← (← IO.getStdin).readToEnd)) + let input ← attachDraft (Daemon.attachClientConfig raw (← IO.getEnv "EGGSHELL_CONFIG")) + -- Use the engine's durable capture before any manager or search operation. + captureTerminal input + captureLateResult input + let event := (optionalString input "hook_event_name").getD "" + let fast := ["Stop", "Interrupt", "PostCompact", "SessionEnd"].contains event || + (event == "SessionStart" && optionalString input "source" == some "compact") + let deadline := (← IO.monoMsNow) + (if fast then 2000 else 26000) + let receipt := Blake3.hex (← IO.getRandomBytes 16) + let input := input.setObjVal! "_eggshell_deadline" (toJson (deadline - 250)) + |>.setObjVal! "_eggshell_receipt" (toJson receipt) + let result ← Daemon.boundedRpc "hook" input (deadline - 150) + rememberTurn input + let some result := result | + throw (IO.userError "memory hook did not return before its delivery deadline; captured results remain queued") + let reply ← IO.ofExcept (Json.parse result) + if let some error := optionalString reply "error" then throw (IO.userError error) + let output ← IO.ofExcept (Json.parse ((optionalString reply "output").getD "{}")) + let session ← IO.ofExcept (requiredString input "session_id") + let writable ← withSession session fun files => do + let state ← readState? files + let pending ← readPendingBase? files + pure <| state.any (·.enabled) && pending.any fun pending => + optionalString input "turn_id" == some pending.turnId && pending.write.isSome + IO.println (Json.mkObj [("ok", toJson true), ("output", output), + ("writable", toJson writable), + ("session_id", input.getObjValD "session_id"), ("receipt", toJson receipt)] |>.compress) + -- The adapter acknowledges only after emitting a supported host response. + pure 0 + catch error => + IO.eprintln s!"Eggshell adapter: {error}" + IO.println (Json.mkObj [("ok", toJson false), ("output", Json.mkObj [])] |>.compress) + pure 0 + +def acknowledgeDelivery : IO UInt32 := do + try + let input ← IO.ofExcept (Json.parse (← (← IO.getStdin).readToEnd)) + let _ ← Daemon.boundedRpc "ack" input ((← IO.monoMsNow) + 2000) + pure 0 + catch error => + IO.eprintln s!"Eggshell adapter receipt: {error}" + pure 1 + +def main (arguments : List String) : IO UInt32 := do + match arguments with + | ["deliver"] => deliver + | ["ack"] => acknowledgeDelivery + | ["draft"] => draftAnswer + | ["codex-daemon", session] => Daemon.run session + | ["codex-worker", role] => Worker.run role + | ["codex-rpc", kind] => Daemon.rpcClient kind + | "egg" :: rest => eggControl rest + | ["--help"] => + IO.println "Eggshell adapter bridge: deliver | ack | egg COMMAND" + pure 0 + | _ => IO.eprintln "invalid adapter bridge command" *> pure 1 diff --git a/adapters/native/lake-manifest.json b/adapters/native/lake-manifest.json new file mode 100644 index 0000000..cc7105b --- /dev/null +++ b/adapters/native/lake-manifest.json @@ -0,0 +1,13 @@ +{"version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": + [{"type": "path", + "scope": "", + "name": "eggshell", + "manifestFile": "lake-manifest.json", + "inherited": false, + "dir": "../..", + "configFile": "lakefile.lean"}], + "name": "eggshellAdapters", + "lakeDir": ".lake", + "fixedToolchain": false} diff --git a/adapters/native/lakefile.lean b/adapters/native/lakefile.lean new file mode 100644 index 0000000..7b2dafb --- /dev/null +++ b/adapters/native/lakefile.lean @@ -0,0 +1,11 @@ +import Lake +open Lake DSL + +package eggshellAdapters where + leanOptions := #[⟨`warningAsError, true⟩] + +require eggshell from "../.." + +@[default_target] +lean_exe eggshell_bridge where + root := `Main diff --git a/adapters/native/lean-toolchain b/adapters/native/lean-toolchain new file mode 100644 index 0000000..025e595 --- /dev/null +++ b/adapters/native/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:v4.33.0 diff --git a/adapters/opencode.mjs b/adapters/opencode.mjs new file mode 100644 index 0000000..4d1fa31 --- /dev/null +++ b/adapters/opencode.mjs @@ -0,0 +1,131 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const adapter = fileURLToPath(new URL('./eggshell_adapter.py', import.meta.url)); +const bridge = fileURLToPath(new URL('./eggshell-bridge', import.meta.url)); +const prefix = fileURLToPath(new URL('../../', import.meta.url)); + +function command(mode, payload, directory) { + return new Promise((resolve, reject) => { + const child = spawn('python3', [adapter, mode, 'opencode', '--bridge', bridge], { + cwd: directory, stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, EGGSHELL_PREFIX: prefix }, + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', (data) => { stdout += data; }); + child.stderr.setEncoding('utf8').on('data', (data) => { stderr += data; }); + child.on('error', reject); + child.on('close', (code) => { + if (stderr) console.error(stderr.trim()); + if (code !== 0) return reject(new Error(`Eggshell adapter exited with ${code}`)); + try { resolve(stdout.trim() ? JSON.parse(stdout) : {}); } + catch (error) { reject(error); } + }); + child.stdin.on('error', () => {}); + child.stdin.end(JSON.stringify(payload)); + }); +} + +/** The runner argument is a transport seam for deterministic contract tests. */ +export function createEggshellHooks(directory, run = command) { + const textParts = new Map(); + const completions = new Map(); + + async function hook(session, event, fields = {}) { + try { + return await run('hook', { + hook_event_name: event, session_id: session, cwd: directory, ...fields, + }, directory); + } catch (error) { + console.error(`Eggshell: ${error.message}`); + return {}; + } + } + + async function ack(receipt) { + if (!receipt.receipt || !receipt.session_id) return; + try { + await run('ack', { receipt: receipt.receipt, session_id: receipt.session_id }, directory); + } catch (error) { + console.error(`Eggshell delivery receipt: ${error.message}`); + } + } + + return { + 'chat.message': async (input, output) => { + const text = output.parts.filter((part) => + part.type === 'text' && !part.synthetic && !part.ignored).map((part) => part.text).join('\n'); + if (!text || !output.message.id) return; + const receipt = await hook(input.sessionID, 'UserPromptSubmit', { + turn_id: output.message.id, prompt: text, + }); + const context = receipt.output?.hookSpecificOutput?.additionalContext; + if (context) { + output.parts.push({ type: 'text', id: `prt_eggshell${randomUUID().replaceAll('-', '')}`, + sessionID: input.sessionID, messageID: output.message.id, synthetic: true, text: context }); + await ack(receipt); + } + }, + 'tool.execute.before': async (input, output) => { + const receipt = await hook(input.sessionID, 'PreToolUse', { + tool_name: input.tool, tool_use_id: input.callID, tool_input: output.args, + }); + const decision = receipt.output?.hookSpecificOutput; + if (decision?.permissionDecision === 'deny') { + await ack(receipt); + throw new Error(decision.permissionDecisionReason); + } + }, + 'tool.execute.after': async (input, output) => { + // Capture the original result before attaching memory to the model output. + const receipt = await hook(input.sessionID, 'PostToolUse', { + tool_name: input.tool, tool_use_id: input.callID, tool_input: input.args, + tool_response: { title: output.title, output: output.output, metadata: output.metadata }, + }); + const context = receipt.output?.hookSpecificOutput?.additionalContext; + if (context) { + output.output += `\n\n${context}`; + await ack(receipt); + } + }, + 'experimental.text.complete': async (input, output) => { + const key = `${input.sessionID}\n${input.messageID}`; + const parts = textParts.get(key) || new Map(); + parts.set(input.partID, output.text); + textParts.set(key, parts); + }, + event: async ({ event }) => { + const properties = event.properties; + if (event.type === 'session.created') { + await hook(properties.info.id, 'SessionStart', { source: 'startup' }); + } else if (event.type === 'session.compacted') { + await hook(properties.sessionID, 'PostCompact'); + } else if (event.type === 'message.updated') { + const message = properties.info; + if (message.role === 'assistant' && message.time?.completed && message.finish === 'stop' && !message.error) { + completions.set(message.sessionID, { id: message.id, turn: message.parentID }); + } + } else if (event.type === 'session.idle') { + const session = properties.sessionID; + const completed = completions.get(session); + const parts = completed && textParts.get(`${session}\n${completed.id}`); + await hook(session, 'Stop', completed ? { + turn_id: completed.turn, + ...(parts ? { last_assistant_message: [...parts.values()].join('\n') } : {}), + } : {}); + completions.delete(session); + for (const key of textParts.keys()) if (key.startsWith(`${session}\n`)) textParts.delete(key); + } else if (event.type === 'session.error') { + if (properties.sessionID) await hook(properties.sessionID, 'Interrupt'); + } else if (event.type === 'session.deleted') { + await hook(properties.info.id, 'SessionEnd'); + completions.delete(properties.info.id); + for (const key of textParts.keys()) if (key.startsWith(`${properties.info.id}\n`)) textParts.delete(key); + } + }, + }; +} + +export const Eggshell = async ({ directory }) => createEggshellHooks(directory); diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index dab02f0..93e9fdc 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -30,11 +30,32 @@ an existing configuration. Ordinary prompts require no special format. ### Installation from a plugin package -The packaged plugin includes an Eggshell setup and inspection skill. After -installing the package in Codex, ask it to set up Eggshell. The bundled setup -helper downloads the runtime for your platform, checks the package's pinned -SHA-256, and installs the runtime without registering another plugin. Review -`/hooks` after setup and start a new chat. +Install the plugin, then ask Codex **“Set up Eggshell for this project.”** The +setup skill installs the runtime and search model, initializes missing project +settings, and preserves existing project and global settings. The download is +checked against the package's pinned SHA-256. It does not register another plugin. + +Review and enable Eggshell in **`/hooks`**, then start a new chat. Look for +**“Eggshell session hook connected”** and run **`!egg doctor`**. This reports +configuration, the current profile, and whether a handoff has been observed in +the current session; it never creates a session, enables memory, or edits a file. +`off` and `read-only` settings remain in effect. A configuration check does not +prove that all hooks are trusted or that a handoff has been delivered. Use the +[two-chat example](try-it.md) to verify saving and reuse. + +If a trusted startup hook finds no runtime or configuration, it shows a setup +message. Missing-runtime hooks stay quiet on tool calls and compaction, and +never download dependencies or prevent the task from continuing. If no startup +message appears, check `/hooks`: an untrusted hook cannot display its own notice. + +To inspect an installation without changes, run +`python3 /scripts/setup.py --check --project `. +For setup, omit `--check`. The default project is the current directory. + +**Supported execution environment:** Codex with local command hooks on macOS or +Linux. Ordinary ChatGPT Chat can expose the setup skill but cannot run this +automatic memory integration. This package connects Codex; other agents use the +separate, experimental [harness adapters](../adapters/README.md). If migrating from the standalone installer, remove its `eggshell@eggshell` plugin registration before enabling the packaged hooks. Retain the runtime and @@ -103,6 +124,7 @@ model. Run session controls inside the chat whose memory you want to manage. !egg off disable recording and handoffs; clear the active turn; retain saved work and queued commits !egg on enable memory again !egg inspect show resolved file paths and saved state identifiers +!egg doctor check setup without changing settings or memory ``` Observed tool results are saved independently while the turn runs. The final diff --git a/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..0abfb0e --- /dev/null +++ b/tests/test_adapters.py @@ -0,0 +1,287 @@ +"""Independent adapters against the real unchanged Eggshell memory engine.""" +import concurrent.futures +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +import test_hook_lifecycle as lifecycle + +ROOT = Path(__file__).resolve().parents[1] +ADAPTER = ROOT / 'adapters/eggshell_adapter.py' +BRIDGE = ROOT / 'adapters/native/.lake/build/bin/eggshell_bridge' +sys.path.insert(0, str(ROOT / 'adapters')) +import eggshell_adapter as adapter +import install as installer + + +class AdapterTests(unittest.TestCase): + def setUp(self): + self.fixture = lifecycle.LifecycleTests() + self.fixture.setUp() + self.root = self.fixture.root + self.env = self.fixture.env + self.env['EGGSHELL_PREFIX'] = str(self.root / 'prefix') + + def tearDown(self): + self.fixture.tearDown() + + def raw(self, client, event, session='chat', turn='turn', **fields): + native = next(key for key, value in adapter.EVENTS[client].items() if value == event) + raw = dict(hook_event_name=native, cwd=str(self.root), **fields) + if client == 'cursor': + raw.update(conversation_id=session, generation_id=turn, workspace_roots=[str(self.root)]) + else: + raw['session_id'] = session + if client == 'opencode': + raw['turn_id'] = turn + if client == 'gemini': + raw.pop('tool_use_id', None) + if 'last_assistant_message' in raw: + raw['prompt_response'] = raw.pop('last_assistant_message') + if client == 'cursor' and 'tool_response' in raw: + raw['tool_output'] = json.dumps(raw.pop('tool_response')) + return raw + + def send(self, client, raw): + result = subprocess.run([sys.executable, ADAPTER, 'hook', client, '--bridge', BRIDGE], + input=json.dumps(raw), text=True, capture_output=True, env=self.env, + cwd=self.root, timeout=35) + self.assertEqual(result.returncode, 0, result.stderr) + try: + parsed = json.loads(result.stdout) + except ValueError: + self.fail('hook did not emit exactly one JSON object: ' + result.stdout + result.stderr) + return parsed + + def hook(self, client, event, session='chat', turn='turn', **fields): + return self.send(client, self.raw(client, event, session, turn, **fields)) + + def start(self, client, session='chat', turn='turn'): + self.hook(client, 'SessionStart', session, turn, source='startup') + return self.hook(client, 'UserPromptSubmit', session, turn, + prompt='Inspect the clock implementation') + + def tool(self, client, marker, call='call', session='chat', turn='turn', command='cat clock.c'): + fields = dict(tool_name='shell', tool_use_id=call, tool_input={'command': command}) + self.hook(client, 'PreToolUse', session, turn, **fields) + return self.hook(client, 'PostToolUse', session, turn, + tool_response={'output': marker}, **fields) + + def state(self, client, session='chat'): + return json.loads((self.fixture.data / 'sessions' / + adapter.session_key(client, session) / 'state.json').read_text()) + + def test_each_adapter_saves_progress_before_stop_and_reuses_in_a_new_chat(self): + for client in adapter.CLIENTS: + with self.subTest(client=client): + session = client + '-writer' + self.start(client, session) + marker = 'partial_fact_from_' + client + command = 'cat ' + client + '-clock.c' + self.tool(client, marker, session=session, command=command) + lifecycle.wait_for(lambda: marker.encode() in self.fixture.egg()) + reader = client + '-reader' + self.start(client, reader) + result = self.hook(client, 'PreToolUse', reader, + tool_name='shell', tool_use_id='reader-probe', + tool_input={'command': command}) + reply = result.get('output', result) + self.assertTrue(adapter.is_denied(reply), result) + + def test_different_harnesses_do_not_share_a_chat_manager(self): + native = '../../same-native-id' + for client in adapter.CLIENTS: + self.start(client, native) + directories = [self.fixture.data / 'sessions' / adapter.session_key(client, native) + for client in adapter.CLIENTS] + endpoints = [json.loads((directory / 'daemon.json').read_text()) for directory in directories] + self.assertEqual(len({endpoint['pid'] for endpoint in endpoints}), 4) + self.assertEqual(len({endpoint['session'] for endpoint in endpoints}), 4) + + def test_unsupported_cursor_prompt_context_is_not_marked_delivered(self): + self.start('claude', 'writer') + self.tool('claude', 'a_saved_clock_result', session='writer') + self.hook('claude', 'Stop', 'writer', last_assistant_message='The clock is monotonic.') + lifecycle.wait_for(lambda: b'The clock is monotonic.' in self.fixture.egg()) + reply = self.start('cursor', 'reader') + self.assertEqual(reply, {}) + self.assertEqual(self.state('cursor', 'reader')['lastHandoff'], '') + + def test_opencode_acknowledges_only_after_host_insertion_and_rejects_stale_ack(self): + self.start('claude', 'writer') + self.tool('claude', 'acknowledgement_marker', session='writer') + lifecycle.wait_for(lambda: b'acknowledgement_marker' in self.fixture.egg()) + self.start('opencode', 'reader') + reply = self.hook('opencode', 'PreToolUse', 'reader', tool_name='shell', + tool_use_id='reuse', tool_input={'command': 'cat clock.c'}) + self.assertTrue(adapter.is_denied(reply['output']), reply) + self.assertEqual(self.state('opencode', 'reader')['lastHandoff'], '') + self.hook('opencode', 'PostCompact', 'reader') + ack = {key: reply[key] for key in ('session_id', 'receipt')} + result = subprocess.run([sys.executable, ADAPTER, 'ack', 'opencode', '--bridge', BRIDGE], + input=json.dumps(ack), text=True, capture_output=True, env=self.env, cwd=self.root) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.state('opencode', 'reader')['lastHandoff'], '') + + def test_cursor_answer_is_final_only_when_the_loop_completes(self): + self.start('cursor') + self.tool('cursor', 'cursor_partial_marker') + self.hook('cursor', 'AssistantMessage', text='A candidate answer before loop completion.') + self.assertNotIn(b'A candidate answer', self.fixture.egg()) + self.hook('cursor', 'Stop', status='aborted') + self.assertNotIn(b'A candidate answer', self.fixture.egg()) + self.start('cursor', turn='next') + self.hook('cursor', 'AssistantMessage', turn='next', text='The final verified clock answer.') + self.hook('cursor', 'Stop', turn='next', status='completed') + lifecycle.wait_for(lambda: b'The final verified clock answer.' in self.fixture.egg()) + + def test_parallel_identical_gemini_tools_keep_both_results(self): + self.start('gemini') + fields = dict(tool_name='run_shell_command', tool_input={'command': 'cat clock.c'}) + with concurrent.futures.ThreadPoolExecutor(2) as pool: + futures = [pool.submit(self.hook, 'gemini', 'PreToolUse', **fields) for _ in range(2)] + for future in futures: + future.result() + with concurrent.futures.ThreadPoolExecutor(2) as pool: + futures = [pool.submit(self.hook, 'gemini', 'PostToolUse', + tool_response={'llmContent': marker}, **fields) + for marker in ('gemini_parallel_one', 'gemini_parallel_two')] + for future in futures: + future.result() + lifecycle.wait_for(lambda: b'gemini_parallel_one' in self.fixture.egg() and + b'gemini_parallel_two' in self.fixture.egg()) + + def test_failed_tools_are_recorded_as_errors(self): + self.start('claude') + self.hook('claude', 'PreToolUse', tool_name='Bash', tool_use_id='failed', + tool_input={'command': 'missing-command'}) + raw = self.raw('claude', 'PostToolUse', tool_name='Bash', tool_use_id='failed', + tool_input={'command': 'missing-command'}, error='command not found') + raw['hook_event_name'] = 'PostToolUseFailure' + self.send('claude', raw) + lifecycle.wait_for(lambda: b'command not found' in self.fixture.egg()) + self.assertIn(b'is_error', self.fixture.egg()) + + def test_late_tool_result_is_saved_under_its_original_turn(self): + self.start('claude') + fields = dict(tool_name='Bash', tool_use_id='late', tool_input={'command': 'cat late.c'}) + self.hook('claude', 'PreToolUse', **fields) + self.hook('claude', 'Stop', last_assistant_message='The earlier task is closed.') + lifecycle.wait_for(lambda: b'The earlier task is closed.' in self.fixture.egg()) + self.hook('claude', 'UserPromptSubmit', prompt='A different subsequent task') + self.hook('claude', 'PostToolUse', tool_response={'output': 'LATE_ORIGINAL_RESULT'}, **fields) + lifecycle.wait_for(lambda: b'LATE_ORIGINAL_RESULT' in self.fixture.egg()) + + def test_off_does_not_save_cursor_answer_or_tool_results(self): + self.start('cursor') + session = adapter.session_key('cursor', 'chat') + subprocess.run([BRIDGE, 'egg', 'off'], env=dict(self.env, CODEX_THREAD_ID=session), + cwd=self.root, capture_output=True, check=True) + self.hook('cursor', 'AssistantMessage', text='PRIVATE_ANSWER_SHOULD_NOT_BE_SAVED') + self.tool('cursor', 'PRIVATE_TOOL_SHOULD_NOT_BE_SAVED') + self.hook('cursor', 'Stop', status='completed') + self.assertNotIn(b'PRIVATE_', self.fixture.egg()) + self.assertFalse(list(self.fixture.data.glob('sessions/*/adapter-drafts/*.json'))) + + def test_one_turn_private_profile_does_not_store_a_cursor_draft(self): + self.fixture.config.write_text(self.fixture.config.read_text() + + '\n[profiles.private]\nread = ["project"]\n') + self.hook('cursor', 'SessionStart', source='startup') + env = dict(self.env, CODEX_THREAD_ID=adapter.session_key('cursor', 'chat')) + subprocess.run([BRIDGE, 'egg', 'next', 'private'], env=env, cwd=self.root, + capture_output=True, check=True) + self.hook('cursor', 'UserPromptSubmit', prompt='A private investigation') + self.hook('cursor', 'AssistantMessage', text='PRIVATE_TURN_ANSWER') + self.assertFalse(list(self.fixture.data.glob('sessions/*/adapter-drafts/*.json'))) + self.hook('cursor', 'Stop', status='completed') + self.assertNotIn(b'PRIVATE_TURN_ANSWER', self.fixture.egg()) + + def test_gemini_ambiguous_results_are_retained_without_a_false_parent(self): + self.start('gemini') + fields = dict(tool_name='run_shell_command', tool_input={'command': 'cat overlap.c'}) + self.hook('gemini', 'PreToolUse', **fields) + self.hook('gemini', 'UserPromptSubmit', prompt='A different overlapping turn') + self.hook('gemini', 'PreToolUse', **fields) + self.hook('gemini', 'PostToolUse', tool_response={'llmContent': 'AMBIGUOUS_RESULT'}, **fields) + files = list((self.fixture.data / 'adapters/unattributed').glob('*/*.json')) + self.assertEqual(len(files), 1) + receipt = json.loads(files[0].read_text()) + self.assertNotIn('turn_id', receipt) + self.assertEqual(receipt['tool_response']['llmContent'], 'AMBIGUOUS_RESULT') + self.assertNotIn(b'AMBIGUOUS_RESULT', self.fixture.egg()) + + def test_gemini_replayed_timestamp_keeps_original_tool_identity(self): + self.start('gemini') + fields = dict(tool_name='run_shell_command', tool_input={'command': 'cat replay.c'}) + self.hook('gemini', 'PreToolUse', timestamp='2026-09-12T00:00:00.000Z', **fields) + raw = self.raw('gemini', 'PostToolUse', timestamp='2026-09-12T00:00:01.000Z', + tool_response={'llmContent': 'REPLAYED_RESULT'}, **fields) + self.send('gemini', raw) + lifecycle.wait_for(lambda: b'REPLAYED_RESULT' in self.fixture.egg()) + before = self.fixture.egg() + self.send('gemini', raw) + self.assertEqual(self.fixture.egg(), before) + + def test_ack_failure_does_not_append_a_second_json_response(self): + raw = self.raw('claude', 'UserPromptSubmit', prompt='A question') + output = io.StringIO() + receipt = dict(ok=True, output={'hookSpecificOutput': {'additionalContext': 'Prior work'}}, + receipt='receipt') + with mock.patch.object(adapter, 'invoke', side_effect=[receipt, RuntimeError('ack unavailable')]), \ + contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()): + adapter.run_hook('claude', raw, BRIDGE, self.root / 'adapter-state') + self.assertEqual(json.loads(output.getvalue())['hookSpecificOutput']['additionalContext'], 'Prior work') + + def test_stop_outputs_never_request_an_agent_retry(self): + malicious = {'continue': True, 'followup_message': 'repeat forever', + 'decision': 'block', 'reason': 'repeat', + 'hookSpecificOutput': {'additionalContext': 'repeat', + 'permissionDecision': 'deny', 'permissionDecisionReason': 'repeat'}} + for client in adapter.CLIENTS: + for event in ('Stop', 'Interrupt', 'SessionEnd'): + self.assertEqual(adapter.translate(client, event, malicious), {}) + + def test_installation_preserves_other_hooks_and_uninstall_removes_only_ours(self): + prefix = self.root / "adapter prefix's" + for client in adapter.CLIENTS: + with self.subTest(client=client): + project = self.root / client + project.mkdir() + config = project / installer.CONFIGS[client] + if client != 'opencode': + config.parent.mkdir(parents=True) + initial = {'otherSetting': True, 'hooks': {'SomeOtherEvent': [{'command': 'keep-me'}]}} + if client == 'cursor': + initial['version'] = 1 + config.write_text(json.dumps(initial)) + installer.install(client, project, prefix, BRIDGE) + first = config.read_text() + installer.install(client, project, prefix, BRIDGE) + self.assertEqual(config.read_text(), first) + installer.install(client, project, prefix, BRIDGE, uninstall=True) + if client == 'opencode': + self.assertFalse(config.exists()) + else: + self.assertEqual(json.loads(config.read_text()), initial) + self.assertTrue((prefix / 'share/eggshell-adapters/eggshell-bridge').exists()) + + def test_invalid_configuration_and_unowned_plugin_are_not_overwritten(self): + for client in ('claude', 'opencode'): + project = self.root / ('invalid-' + client) + config = project / installer.CONFIGS[client] + config.parent.mkdir(parents=True) + config.write_text('preserve this unowned content') + with self.assertRaises(ValueError): + installer.install(client, project, self.root / 'prefix', BRIDGE) + self.assertEqual(config.read_text(), 'preserve this unowned content') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_opencode_adapter.mjs b/tests/test_opencode_adapter.mjs new file mode 100644 index 0000000..c9692ce --- /dev/null +++ b/tests/test_opencode_adapter.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createEggshellHooks } from '../adapters/opencode.mjs'; + +function receipt(context) { + return { output: { hookSpecificOutput: { additionalContext: context } }, + receipt: 'receipt', session_id: 'opencode-native-session' }; +} + +test('prompt context is a separate synthetic part and ack follows insertion', async () => { + const original = { type: 'text', text: 'Inspect clock.c', id: 'prt_original' }; + const output = { message: { id: 'msg_user' }, parts: [original] }; + const calls = []; + const hooks = createEggshellHooks('/project', async (mode, payload) => { + calls.push([mode, payload]); + if (mode === 'ack') assert.equal(output.parts[1].text, 'Saved work'); + return mode === 'hook' ? receipt('Saved work') : {}; + }); + await hooks['chat.message']({ sessionID: 'session' }, output); + assert.deepEqual(output.parts[0], original); + assert.equal(output.parts[1].synthetic, true); + assert.equal(calls[0][1].prompt, 'Inspect clock.c'); + assert.equal(calls[0][1].turn_id, 'msg_user'); + assert.deepEqual(calls.map(([mode]) => mode), ['hook', 'ack']); +}); + +test('tool capture excludes the memory that is added afterwards', async () => { + const output = { title: 'Read clock', output: 'original result', metadata: {} }; + const hooks = createEggshellHooks('/project', async (mode, payload) => { + if (mode === 'hook') { + assert.equal(payload.tool_response.output, 'original result'); + return receipt('Prior evidence'); + } + assert.equal(output.output, 'original result\n\nPrior evidence'); + return {}; + }); + await hooks['tool.execute.after']({ sessionID: 'session', callID: 'tool', tool: 'read', + args: { filePath: '/project/clock.c' } }, output); +}); + +test('a covered operation is denied through the tool hook, with its reason intact', async () => { + const calls = []; + const hooks = createEggshellHooks('/project', async (mode) => { + calls.push(mode); + return { output: { hookSpecificOutput: { + permissionDecision: 'deny', permissionDecisionReason: 'Reuse the supported result.', + } }, receipt: 'receipt', session_id: 'opencode-native-session' }; + }); + await assert.rejects(hooks['tool.execute.before']({ sessionID: 'session', callID: 'tool', tool: 'read' }, + { args: { filePath: 'clock.c' } }), /Reuse the supported result/); + assert.deepEqual(calls, ['hook', 'ack']); +}); + +test('a completed assistant answer is saved only when the agent becomes idle', async () => { + const events = []; + const hooks = createEggshellHooks('/project', async (mode, payload) => { + events.push(payload); + return {}; + }); + await hooks['experimental.text.complete']({ sessionID: 'session', messageID: 'answer', partID: 'part' }, + { text: 'Final supported answer' }); + assert.equal(events.length, 0); + await hooks.event({ event: { type: 'message.updated', properties: { info: { + role: 'assistant', id: 'answer', sessionID: 'session', parentID: 'user-turn', + time: { completed: 123 }, finish: 'stop', + } } } }); + assert.equal(events.length, 0); + await hooks.event({ event: { type: 'session.idle', properties: { sessionID: 'session' } } }); + assert.deepEqual(events.map((event) => event.hook_event_name), ['Stop']); + assert.equal(events[0].last_assistant_message, 'Final supported answer'); + assert.equal(events[0].turn_id, 'user-turn'); +}); + +test('tool-call messages are not treated as completed answers', async () => { + const events = []; + const hooks = createEggshellHooks('/project', async (mode, payload) => { events.push(payload); return {}; }); + await hooks.event({ event: { type: 'message.updated', properties: { info: { + role: 'assistant', id: 'tools', sessionID: 'session', parentID: 'user-turn', + time: { completed: 123 }, finish: 'tool-calls', + } } } }); + await hooks.event({ event: { type: 'session.idle', properties: { sessionID: 'session' } } }); + assert.equal(events[0].last_assistant_message, undefined); +}); + +test('compaction and stop never add an automatic follow-up or modify the compaction prompt', async () => { + const events = []; + const hooks = createEggshellHooks('/project', async (mode, payload) => { events.push(payload); return {}; }); + assert.equal(hooks['experimental.session.compacting'], undefined); + await hooks.event({ event: { type: 'session.compacted', properties: { sessionID: 'session' } } }); + await hooks.event({ event: { type: 'session.idle', properties: { sessionID: 'session' } } }); + assert.deepEqual(events.map((event) => event.hook_event_name), ['PostCompact', 'Stop']); + assert.ok(events.every((event) => !('followup_message' in event))); +}); From 659c8a62b7d1fd3f84f2e33f8251add55e4ee74f Mon Sep 17 00:00:00 2001 From: Hayato Ohhashi Date: Sat, 12 Sep 2026 20:51:15 +0900 Subject: [PATCH 4/4] Move retrieval, setup and harness adapters to verified Lean --- .github/workflows/adapters.yml | 8 +- .github/workflows/ci.yml | 7 +- .github/workflows/plugin-package.yml | 16 +- .github/workflows/release.yml | 12 +- CONTRIBUTING.md | 16 +- Eggshell/ContractAudit.lean | 31 + Eggshell/MiniLM.lean | 193 +- Eggshell/SearchProvider.lean | 195 ++ Eggshell/SearchRank.lean | 102 + Eggshell/SearchUnicode.lean | 1660 +++++++++++++++++ Eggshell/Setup.lean | 56 + Eggshell/Sha256.lean | 65 + Main.lean | 6 +- PRIVACY.md | 4 +- README.md | 27 +- TestMain.lean | 8 +- adapters/README.md | 45 +- adapters/eggshell_adapter.py | 395 ---- adapters/install.py | 170 -- adapters/native/Adapter/Bridge.lean | 115 ++ adapters/native/Adapter/ContractAudit.lean | 32 + adapters/native/Adapter/Contracts.lean | 198 ++ adapters/native/Adapter/Install.lean | 167 ++ adapters/native/Adapter/Protocol.lean | 167 ++ adapters/native/Adapter/Runtime.lean | 150 ++ adapters/native/Main.lean | 158 +- adapters/native/Tests.lean | 228 +++ adapters/native/lakefile.lean | 5 + adapters/opencode.mjs | 3 +- .../brand/github-social-preview-1280x640.png | Bin 90199 -> 102374 bytes .../brand/github-social-preview-1280x640.svg | 43 +- docs/brand.md | 5 +- docs/codex-plugin.md | 2 +- docs/demo.md | 10 +- docs/hook-lifecycle.md | 2 +- docs/lean-boundaries.md | 84 + docs/try-it.md | 2 +- lakefile.lean | 15 + plugins/eggshell/scripts/setup.py | 124 -- plugins/eggshell/scripts/setup.sh | 62 + plugins/eggshell/skills/eggshell/SKILL.md | 4 +- runtime/embedding.py | 30 + scripts/package_plugin.py | 72 - scripts/render_demo.py | 245 --- scripts/render_social_preview.py | 43 - tests/LifecycleTests.lean | 329 ++++ tests/SearchTests.lean | 33 + tests/SetupPackageTests.lean | 159 ++ tests/fixtures/README.md | 14 + tests/fixtures/search-golden.jsonl | 24 + tests/test_adapters.py | 287 --- tests/test_hook_lifecycle.py | 443 ----- tests/test_plugin_package.py | 269 --- tests/test_search_provider.py | 64 - tools/Package.lean | 113 ++ tools/Render.lean | 47 + 56 files changed, 4219 insertions(+), 2545 deletions(-) create mode 100644 Eggshell/ContractAudit.lean create mode 100644 Eggshell/SearchProvider.lean create mode 100644 Eggshell/SearchRank.lean create mode 100644 Eggshell/SearchUnicode.lean create mode 100644 Eggshell/Setup.lean create mode 100644 Eggshell/Sha256.lean delete mode 100644 adapters/eggshell_adapter.py delete mode 100644 adapters/install.py create mode 100644 adapters/native/Adapter/Bridge.lean create mode 100644 adapters/native/Adapter/ContractAudit.lean create mode 100644 adapters/native/Adapter/Contracts.lean create mode 100644 adapters/native/Adapter/Install.lean create mode 100644 adapters/native/Adapter/Protocol.lean create mode 100644 adapters/native/Adapter/Runtime.lean create mode 100644 adapters/native/Tests.lean create mode 100644 docs/lean-boundaries.md delete mode 100644 plugins/eggshell/scripts/setup.py create mode 100644 plugins/eggshell/scripts/setup.sh create mode 100644 runtime/embedding.py delete mode 100644 scripts/package_plugin.py delete mode 100644 scripts/render_demo.py delete mode 100644 scripts/render_social_preview.py create mode 100644 tests/LifecycleTests.lean create mode 100644 tests/SearchTests.lean create mode 100644 tests/SetupPackageTests.lean create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/search-golden.jsonl delete mode 100644 tests/test_adapters.py delete mode 100644 tests/test_hook_lifecycle.py delete mode 100644 tests/test_plugin_package.py delete mode 100644 tests/test_search_provider.py create mode 100644 tools/Package.lean create mode 100644 tools/Render.lean diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml index 71c844a..70c3c8e 100644 --- a/.github/workflows/adapters.yml +++ b/.github/workflows/adapters.yml @@ -6,9 +6,8 @@ on: paths: - 'adapters/**' - 'Eggshell/**' - - 'tests/test_adapters.py' + - 'adapters/native/Tests.lean' - 'tests/test_opencode_adapter.mjs' - - 'tests/test_hook_lifecycle.py' - '.github/workflows/adapters.yml' - 'lean-toolchain' - 'lakefile.lean' @@ -31,8 +30,9 @@ jobs: auto-config: false - name: Build independent adapter companion working-directory: adapters/native - run: lake build + run: lake build eggshell_bridge adapter_tests - name: Engine integration contracts - run: python3 tests/test_adapters.py -v + working-directory: adapters/native + run: .lake/build/bin/adapter_tests - name: OpenCode output contracts run: node --test tests/test_opencode_adapter.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8be163..73f04b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,16 +22,17 @@ jobs: with: auto-config: false - name: Build - run: lake build eggshell eggshell_tests + run: lake build eggshell eggshell_tests lifecycle_tests eggshell_package setup_package_tests - name: Test run: EGGSHELL_DATA_ROOT="$PWD/.lake/eggshell-tests-data" .lake/build/bin/eggshell_tests - name: Hook lifecycle regressions - run: python3 tests/test_hook_lifecycle.py -v + run: .lake/build/bin/lifecycle_tests - name: Check plugin package - run: python3 tests/test_plugin_package.py + run: .lake/build/bin/setup_package_tests - name: Check installer syntax run: | sh -n install.sh sh -n plugins/eggshell/bin/egg + sh -n plugins/eggshell/scripts/setup.sh - name: Check two-chat sample run: python3 -m unittest discover -s examples/two-chats -v diff --git a/.github/workflows/plugin-package.yml b/.github/workflows/plugin-package.yml index 499bdd9..50a0da2 100644 --- a/.github/workflows/plugin-package.yml +++ b/.github/workflows/plugin-package.yml @@ -28,10 +28,10 @@ jobs: auto-config: false - name: Build and test run: | - lake build eggshell eggshell_tests + lake build eggshell eggshell_tests lifecycle_tests eggshell_package setup_package_tests EGGSHELL_DATA_ROOT="$PWD/.lake/eggshell-tests-data" .lake/build/bin/eggshell_tests - python3 tests/test_hook_lifecycle.py -v - python3 tests/test_plugin_package.py + .lake/build/bin/lifecycle_tests + .lake/build/bin/setup_package_tests - name: Package runtime run: | mkdir -p dist @@ -48,6 +48,9 @@ jobs: contents: write steps: - uses: actions/checkout@v5 + - uses: leanprover/lean-action@v1 + with: + auto-config: false - uses: actions/download-artifact@v4 with: pattern: runtime-* @@ -55,13 +58,14 @@ jobs: path: dist/runtimes - name: Assemble plugin run: | - version="$(python3 -c 'import json; print(json.load(open("plugins/eggshell/.codex-plugin/plugin.json"))["version"])')" - python3 scripts/package_plugin.py --runtime-dir dist/runtimes --output dist/publish --release "v$version" + lake build eggshell_package + version="$(.lake/build/bin/eggshell_package --version)" + .lake/build/bin/eggshell_package --runtime-dir dist/runtimes --output dist/publish --release "v$version" - name: Publish pinned runtime assets and plugin ZIP env: GH_TOKEN: ${{ github.token }} run: | - version="$(python3 -c 'import json; print(json.load(open("plugins/eggshell/.codex-plugin/plugin.json"))["version"])')" + version="$(.lake/build/bin/eggshell_package --version)" gh release view "v$version" --repo "$GITHUB_REPOSITORY" gh release upload "v$version" dist/publish/*.tar.gz dist/publish/eggshell-codex-plugin.zip --clobber --repo "$GITHUB_REPOSITORY" - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5024f6..6e7c96f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,10 +54,10 @@ jobs: auto-config: false - name: Build and test run: | - lake build eggshell eggshell_tests + lake build eggshell eggshell_tests lifecycle_tests eggshell_package setup_package_tests EGGSHELL_DATA_ROOT="$PWD/.lake/eggshell-tests-data" .lake/build/bin/eggshell_tests - python3 tests/test_hook_lifecycle.py -v - python3 tests/test_plugin_package.py + .lake/build/bin/lifecycle_tests + .lake/build/bin/setup_package_tests - name: Package shell: bash run: | @@ -84,6 +84,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + - uses: leanprover/lean-action@v1 + with: + auto-config: false - uses: actions/download-artifact@v4 with: pattern: release-* @@ -91,7 +94,8 @@ jobs: path: dist/runtimes - name: Assemble plugin from tested runtimes run: | - python3 scripts/package_plugin.py --runtime-dir dist/runtimes --output dist/publish --release "$GITHUB_REF_NAME" + lake build eggshell_package + .lake/build/bin/eggshell_package --runtime-dir dist/runtimes --output dist/publish --release "$GITHUB_REF_NAME" - name: Publish runtime assets and plugin together env: GH_TOKEN: ${{ github.token }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c4ea7d..5bc679c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,9 +17,9 @@ EGGSHELL_DATA_ROOT="$PWD/.lake/eggshell-tests-data" \ Tests must use an isolated absolute `EGGSHELL_DATA_ROOT`; they refuse the normal user data directory. Keep public claims tied to completed, reproducible measurements. -The shipped local search provider is also exercised with its installed MiniLM -Python environment: `python tests/test_search_provider.py`. The model must already -be cached; the test uses offline mode and makes no generative model requests. +Build `search_tests` and run `.lake/build/bin/search_tests` to exercise the shipped +local search provider. Its MiniLM numerical runtime and model must already be +cached; the test uses offline mode and makes no generative model requests. For performance work, total tokens mean input plus reasoning output plus final output. Quality non-regression and `.egg` growth are constraints; tool count and elapsed time are diagnostics. @@ -37,13 +37,13 @@ private benchmark transcripts. launcher. The standalone installer embeds its manifest and hooks in `Eggshell/Install.lean`; the existing test enforces that those definitions match. -Run `python3 tests/test_plugin_package.py` after building the executable when -changing package setup or runtime installation. These checks exercise checksum -rejection, archive validation, missing-runtime hooks, and preservation of -existing plugin registration and memory during runtime-only installation. +Build `eggshell`, `eggshell_package`, and `setup_package_tests`, then run +`.lake/build/bin/setup_package_tests` when changing setup or packaging. +These native tests check configuration preservation, read-only inspection, +runtime checksums, ZIP readability, and deterministic packaging. The manually dispatched **Plugin package** workflow builds and tests all four -platform runtimes, then runs `scripts/package_plugin.py`. It publishes a small +platform runtimes, then runs the Lean `eggshell_package` executable. It publishes a small `eggshell-codex-plugin.zip` and runtime archives whose names include their content hashes on the release matching the plugin version. Existing standalone release archives and the release tag are preserved. The ZIP pins the exact runtime diff --git a/Eggshell/ContractAudit.lean b/Eggshell/ContractAudit.lean new file mode 100644 index 0000000..68c9604 --- /dev/null +++ b/Eggshell/ContractAudit.lean @@ -0,0 +1,31 @@ +module + +import Eggshell.SearchRank +import Eggshell.SearchProvider +import Eggshell.Setup +public meta import Lean + +open Lean + +/- This module is imported by the test executable, not the product executable. + Reject admissions and any new axiom outside Lean's standard logical basis. -/ +run_meta do + let contracts := #[ + ``Eggshell.SearchRank.unique_indices, + ``Eggshell.SearchRank.selected_no_duplicates, + ``Eggshell.SearchRank.selected_within_budget, + ``Eggshell.SearchRank.selected_is_existing, + ``Eggshell.SearchRank.selected_was_ranked, + ``Eggshell.SearchRank.zero_budget_is_empty, + ``Eggshell.Setup.configured_is_preserved, + ``Eggshell.Setup.check_never_initializes, + ``Eggshell.Setup.initialize_only_when_missing, + ``Eggshell.SearchProvider.accepted_cache_identity, + ``Eggshell.SearchProvider.changed_cache_text_rejected, + ``Eggshell.SearchProvider.changed_cache_model_rejected] + for contract in contracts do + let axioms ← Lean.collectAxioms contract + for dependency in axioms do + unless #[``propext, ``Quot.sound, ``Classical.choice].contains dependency do + throwError "{contract} depends on unapproved axiom {dependency}" + logInfo m!"Audited {contract}: {axioms}" diff --git a/Eggshell/MiniLM.lean b/Eggshell/MiniLM.lean index 0315c7d..7592508 100644 --- a/Eggshell/MiniLM.lean +++ b/Eggshell/MiniLM.lean @@ -11,183 +11,7 @@ def model : String := def runtimeVersion : String := "fastembed-0.8.0" -def providerSource : String := r#"import argparse -import hashlib -import json -import math -import os -import re -import sqlite3 -import sys -from collections import Counter - -import numpy as np -from fastembed import TextEmbedding - - -def arguments(): - parser = argparse.ArgumentParser() - parser.add_argument("--cache", required=True) - parser.add_argument("--model-cache", required=True) - parser.add_argument("--model", default="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") - parser.add_argument("--top-k", type=int, default=8) - parser.add_argument("--threshold", type=float, default=0.38) - parser.add_argument("--mode", choices=["semantic", "lexical", "hybrid"], default="hybrid") - parser.add_argument("--anchor-k", type=int, default=2) - parser.add_argument("--trace") - parser.add_argument("--preload", action="store_true") - return parser.parse_args() - - -def normalized(vector): - value = np.asarray(vector, dtype=np.float32) - norm = np.linalg.norm(value) - return value if norm == 0 else value / norm - - -def windows(text): - # Bound encoder input, not the authoritative Outcome returned to the kernel. - return [text[start:start + 512] for start in range(0, max(1, len(text)), 384)] - - -def terms(text): - return re.findall(r"[a-z0-9_./:-]+|[\u3040-\u30ff\u3400-\u9fff]", text.casefold()) - - -def lexical_ranking(query, candidates): - wanted = set(terms(query)) - documents = [set(terms(item["text"])) for item in candidates] - counts = Counter(term for document in documents for term in document) - scores = [(sum(math.log1p(len(documents) / counts[term]) - for term in wanted & document), index) - for index, document in enumerate(documents)] - return [index for score, index in sorted(scores, key=lambda pair: (-pair[0], pair[1])) if score > 0] - - -def lexical_anchor_ranking(query, candidates): - # Preserve exact identifiers and paths in the final hybrid ranking. - wanted = {term for term in terms(query) - if "_" in term or "/" in term or ":" in term or "." in term - or any(character.isdigit() for character in term)} - documents = [set(terms(item["text"])) for item in candidates] - counts = Counter(term for document in documents for term in document) - scores = [(sum(math.log1p(len(documents) / counts[term]) - for term in wanted & document), index) - for index, document in enumerate(documents)] - return [index for score, index in sorted(scores, key=lambda pair: (-pair[0], pair[1])) if score > 0] - - -def fuse(rankings, limit): - scores = Counter() - for ranking in rankings: - for rank, index in enumerate(ranking): - scores[index] += 1 / (60 + rank + 1) - return sorted(scores, key=lambda index: (-scores[index], index))[:limit] - - -def anchor_first(anchors, ranking, limit): - selected = [] - for index in anchors + ranking: - if index not in selected: - selected.append(index) - if len(selected) == limit: - break - return selected - - -def main(): - options = arguments() - os.makedirs(options.cache, exist_ok=True) - os.makedirs(options.model_cache, exist_ok=True) - database = sqlite3.connect(os.path.join(options.cache, "window-vectors-v2.sqlite")) - database.execute("create table if not exists vectors (id text primary key, vector blob not null)") - encoder = None if options.mode == "lexical" else TextEmbedding( - model_name=options.model, - cache_dir=options.model_cache, - threads=max(1, min(4, os.cpu_count() or 1)), - ) - def write_trace(record): - if not options.trace: - return - try: - parent = os.path.dirname(options.trace) - if parent: - os.makedirs(parent, exist_ok=True) - with open(options.trace, "a", encoding="utf-8") as output: - output.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") - except Exception as error: - print("trace error: " + str(error), file=sys.stderr, flush=True) - - def key(text): - return hashlib.sha256((options.model + "\0" + text).encode()).hexdigest() - - def lookup(identifier): - row = database.execute("select vector from vectors where id = ?", (identifier,)).fetchone() - return None if row is None else np.frombuffer(row[0], dtype=np.float32) - - def store(items): - texts = list(dict.fromkeys(part for item in items for part in windows(item["text"]))) - missing = [text for text in texts if lookup(key(text)) is None] - if not missing: - return - vectors = encoder.embed(missing, batch_size=32) - database.executemany( - "insert or replace into vectors(id, vector) values (?, ?)", - ((key(text), normalized(vector).tobytes()) for text, vector in zip(missing, vectors)), - ) - database.commit() - - if options.preload: - if encoder is not None: - next(encoder.embed(["eggshell"], batch_size=1)) - return - - for raw in sys.stdin: - try: - request = json.loads(raw) - if "index" in request: - if encoder is not None: - store(request["index"]) - continue - candidates = request.get("candidates", []) - lexical = lexical_ranking(request["query"]["text"], candidates) - anchors = lexical_anchor_ranking(request["query"]["text"], candidates) - scored = [] - if encoder is not None: - store(candidates + [request["query"]]) - queries = [lookup(key(part)) for part in windows(request["query"]["text"])] - for index, candidate in enumerate(candidates): - vectors = [lookup(key(part)) for part in windows(candidate["text"])] - score = max(float(np.dot(query, vector)) for query in queries for vector in vectors) - scored.append((score, index)) - semantic_all = [index for _, index in sorted(scored, key=lambda pair: (-pair[0], pair[1]))] - semantic = [index for score, index in sorted(scored, key=lambda pair: (-pair[0], pair[1])) - if score >= options.threshold] - hybrid_base = fuse([lexical, semantic], options.top_k) - base = (hybrid_base if options.mode == "hybrid" - else (lexical if options.mode == "lexical" else semantic)) - ranking = anchor_first(anchors[:options.anchor_k], base, options.top_k) - write_trace({ - "mode": options.mode, - "candidate_count": len(candidates), - "candidate_ids": [item.get("id", "") for item in candidates], - "lexical_rank": lexical, - "anchor_rank": anchors, - "semantic_rank": semantic_all, - "semantic_threshold_rank": semantic, - "hybrid_rank": hybrid_base, - "selected": ranking, - }) - # Keep the provider wire response stable; diagnostics live in the trace sidecar. - print(json.dumps({"related": ranking}), flush=True) - except Exception as error: - print(str(error), file=sys.stderr, flush=True) - print(json.dumps({"related": []}), flush=True) - - -if __name__ == "__main__": - main() -"# +def embeddingSource : String := include_str "../runtime/embedding.py" structure Layout where support : System.FilePath @@ -205,7 +29,7 @@ def layout (root pluginData : System.FilePath) : Layout := { support runtime := support / runtimeVersion - provider := support / "provider.py" + provider := support / "embedding.py" models := support / "models" vectors := pluginData / "semantic" / "minilm" trace := pluginData / "semantic" / "matcher-trace.jsonl" @@ -246,7 +70,7 @@ def install (root : System.FilePath) : IO Unit := do let support := supportRoot root let paths := layout root (support / "preload") IO.FS.createDirAll paths.support - IO.FS.writeFile paths.provider providerSource + IO.FS.writeFile paths.provider embeddingSource let python ← match ← runtimePython? paths with | some python => pure python | none => do @@ -263,18 +87,13 @@ def install (root : System.FilePath) : IO Unit := do throw error let ready := paths.support / s!"{runtimeVersion}.model-ready" if !(← ready.pathExists) then - process python.toString #[paths.provider.toString, - "--cache", paths.vectors.toString, - "--model-cache", paths.models.toString, - "--model", model, - "--preload"] + process python.toString #["-c", embeddingSource, model, paths.models.toString, "4", "--preload"] IO.FS.writeFile ready model def command (root pluginData : System.FilePath) : IO (Option (List String)) := do let paths := layout root pluginData - let some python ← runtimePython? paths | pure none - if !(← paths.provider.pathExists) then pure none - else pure (some [python.toString, paths.provider.toString, + let some _ ← runtimePython? paths | pure none + pure (some [(← IO.appPath).toString, "search-provider", "--cache", paths.vectors.toString, "--model-cache", paths.models.toString, "--model", model, diff --git a/Eggshell/SearchProvider.lean b/Eggshell/SearchProvider.lean new file mode 100644 index 0000000..6bf2762 --- /dev/null +++ b/Eggshell/SearchProvider.lean @@ -0,0 +1,195 @@ +module + +public import Eggshell.SearchRank +public import Eggshell.MiniLM +public import Eggshell.Sha256 +public import Eggshell.Persistence +public import Lean.Data.Json.FromToJson + +@[expose] public section + +namespace Eggshell.SearchProvider +open Lean + +structure Options where + cache : System.FilePath + models : System.FilePath + model : String := MiniLM.model + mode : String := "hybrid" + topK : Nat := 8 + anchorK : Nat := 2 + threshold : Float := 0.38 + trace : Option System.FilePath := none + +def number (json : Json) : Except String Float := do + match json with + | .num value => + let result := value.toFloat + if result.isFinite then pure result else throw "expected finite JSON number" + | _ => throw "expected finite JSON number" + +def natural (value : String) : Except String Nat := + match value.toNat? with + | some n => .ok n + | none => .error "expected a natural number" + +def parse (defaults : Options) : List String → Except String Options + | [] => .ok defaults + | "--cache" :: v :: rest => parse { defaults with cache := .mk v } rest + | "--model-cache" :: v :: rest => parse { defaults with models := .mk v } rest + | "--model" :: v :: rest => parse { defaults with model := v } rest + | "--mode" :: v :: rest => + if ["lexical", "semantic", "hybrid"].contains v then parse { defaults with mode := v } rest + else .error "unsupported search mode" + | "--top-k" :: v :: rest => do parse { defaults with topK := ← natural v } rest + | "--anchor-k" :: v :: rest => do parse { defaults with anchorK := ← natural v } rest + | "--threshold" :: v :: rest => do parse { defaults with threshold := ← number (← Json.parse v) } rest + | "--trace" :: v :: rest => parse { defaults with trace := some (.mk v) } rest + | _ => .error "invalid search-provider arguments" + +abbrev Encoder := IO.Process.Child { stdin := .piped, stdout := .piped, stderr := .inherit } + +def encoder (options : Options) : IO Encoder := do + let layout := MiniLM.layout (← Paths.installRoot) options.cache + let some python ← MiniLM.runtimePython? layout | throw (IO.userError "MiniLM runtime is not installed") + IO.Process.spawn { + cmd := python.toString + args := #["-c", MiniLM.embeddingSource, options.model, options.models.toString, "4"] + stdin := .piped + stdout := .piped + stderr := .inherit } + +def exchange (child : Encoder) (input : Json) : IO Json := do + child.stdin.putStrLn input.compress + child.stdin.flush + let result ← IO.ofExcept (Json.parse (← child.stdout.getLine)) + if let .ok error := result.getObjValAs? String "error" then throw (IO.userError error) + pure result + +/-- Persisted vectors carry the exact model and source bytes. The hash is only + a locator; a collision or damaged record cannot authorize different text. -/ +structure CachedVector where + model : String + text : String + vector : Json + deriving ToJson, FromJson + +def cacheMatches (model text : String) (cached : CachedVector) : Bool := + decide (cached.model = model ∧ cached.text = text) + +theorem accepted_cache_identity (model text : String) (cached : CachedVector) + (h : cacheMatches model text cached = true) : cached.model = model ∧ cached.text = text := by + exact of_decide_eq_true h + +theorem changed_cache_text_rejected (model text : String) (cached : CachedVector) + (h : cached.text ≠ text) : cacheMatches model text cached = false := by + simp [cacheMatches, h] + +theorem changed_cache_model_rejected (model text : String) (cached : CachedVector) + (h : cached.model ≠ model) : cacheMatches model text cached = false := by + simp [cacheMatches, h] + +def cacheKey (model text : String) : String := Sha256.hex (model ++ "\x00" ++ text).toUTF8 + +def readVector (options : Options) (text : String) : IO (Option Json) := do + let path := options.cache / "vectors" / (cacheKey options.model text ++ ".json") + if !(← path.pathExists) then return none + try + let cached ← IO.ofExcept (fromJson? (← IO.ofExcept (Json.parse (← IO.FS.readFile path))) : Except String CachedVector) + if cacheMatches options.model text cached then return some cached.vector + throw (IO.userError "embedding cache identity mismatch") + catch _ => return none + +def saveVector (options : Options) (text : String) (vector : Json) : IO Unit := do + let root := options.cache / "vectors" + Persistence.privateDirectory root + let path := root / (cacheKey options.model text ++ ".json") + let temp := System.FilePath.mk (path.toString ++ ".tmp-" ++ toString (← IO.Process.getPID)) + IO.FS.writeFile temp (toJson ({ model := options.model, text, vector } : CachedVector)).compress + Persistence.privateFile temp + IO.FS.rename temp path + +def store (options : Options) (child : Encoder) (texts : List String) : IO Unit := do + let parts := (texts.flatMap SearchRank.windows).eraseDups + let mut missing := [] + for part in parts do if (← readVector options part).isNone then missing := missing ++ [part] + if missing.isEmpty then return + let response ← exchange child (Json.mkObj [("texts", toJson missing)]) + let vectors ← IO.ofExcept (response.getObjValAs? (Array Json) "vectors") + if vectors.size != missing.length then throw (IO.userError "embedding count mismatch") + for (text, vector) in missing.zip vectors.toList do saveVector options text vector + +def vectors (options : Options) (text : String) : IO Json := do + let values ← (SearchRank.windows text).mapM fun part => do + let some value ← readVector options part | throw (IO.userError "missing indexed embedding") + pure value + pure (.arr values.toArray) + +def trace (options : Options) (record : Json) : IO Unit := do + if let some path := options.trace then + try + if let some parent := path.parent then Persistence.privateDirectory parent + let file ← IO.FS.Handle.mk path .append + Persistence.privateFile path + file.putStrLn record.compress + file.flush + catch error => IO.eprintln s!"Eggshell trace: {error}" + +def handle (options : Options) (child : Option Encoder) (request : Json) : IO (Option Json) := do + if let .ok index := request.getObjValAs? (Array Json) "index" then + if let some child := child then + let texts ← IO.ofExcept (index.toList.mapM (·.getObjValAs? String "text")) + store options child texts + return none + let query ← IO.ofExcept ((request.getObjValD "query").getObjValAs? String "text") + let candidates ← IO.ofExcept (request.getObjValAs? (Array Json) "candidates") + let texts ← IO.ofExcept (candidates.toList.mapM (·.getObjValAs? String "text")) + let lexical := SearchRank.lexical query texts + let anchors := SearchRank.lexical query texts true + let mut scored : List (Float × Nat) := [] + if let some child := child then + store options child (texts ++ [query]) + let response ← exchange child (Json.mkObj [("queries", ← vectors options query), + ("candidates", .arr (← texts.toArray.mapM (vectors options)))]) + let scores ← IO.ofExcept (response.getObjValAs? (Array Json) "scores") + if scores.size != candidates.size then throw (IO.userError "similarity count mismatch") + scored ← IO.ofExcept (scores.toList.zipIdx.mapM fun (value, index) => do pure (← number value, index)) + let ordered := scored.mergeSort SearchRank.scoreOrder + let semantic := (ordered.filter (·.1 ≥ options.threshold)).map (·.2) + let hybrid := (SearchRank.fused [lexical, semantic]).take options.topK + let base := if options.mode == "hybrid" then hybrid else if options.mode == "lexical" then lexical else semantic + let selected := SearchRank.select candidates.size options.topK (anchors.take options.anchorK) base + trace options (Json.mkObj [("mode", .str options.mode), ("candidate_count", toJson candidates.size), + ("candidate_ids", .arr (candidates.map (·.getObjValD "id"))), + ("lexical_rank", toJson lexical), ("anchor_rank", toJson anchors), + ("semantic_rank", toJson (ordered.map (·.2))), ("semantic_threshold_rank", toJson semantic), + ("hybrid_rank", toJson hybrid), ("selected", toJson selected)]) + pure (some (Json.mkObj [("related", toJson selected)])) + +def run (args : List String) : IO UInt32 := do + let layout := MiniLM.layout (← Paths.installRoot) (← Paths.dataRoot) + let options ← IO.ofExcept (parse { cache := layout.vectors, models := layout.models } args) + let child ← if options.mode == "lexical" then pure none else some <$> encoder options + try + let stdin ← IO.getStdin + let stdout ← IO.getStdout + repeat + let line ← stdin.getLine + if line.isEmpty then break + try + let request ← IO.ofExcept (Json.parse line) + if let some result ← handle options child request then + stdout.putStrLn result.compress + stdout.flush + catch error => + IO.eprintln s!"Eggshell search: {error}" + stdout.putStrLn "{\"related\":[]}" + stdout.flush + pure 0 + finally + if let some child := child then + child.kill + let _ ← child.wait + pure () + +end Eggshell.SearchProvider diff --git a/Eggshell/SearchRank.lean b/Eggshell/SearchRank.lean new file mode 100644 index 0000000..4943eda --- /dev/null +++ b/Eggshell/SearchRank.lean @@ -0,0 +1,102 @@ +module + +public import Eggshell.SearchUnicode +public import Lean.Data.Json + +@[expose] public section + +namespace Eggshell.SearchRank + +@[extern "log1p"] opaque log1p (x : Float) : Float + +def asciiTerm (c : Char) : Bool := + ('a' ≤ c && c ≤ 'z') || ('0' ≤ c && c ≤ '9') || "_./:-".contains c + +def singleTerm (c : Char) : Bool := + (0x3040 ≤ c.toNat && c.toNat ≤ 0x30ff) || (0x3400 ≤ c.toNat && c.toNat ≤ 0x9fff) + +def terms (text : String) : List String := Id.run do + let folded := text.toList.flatMap (SearchUnicode.fold · |>.toList) + let mut result := [] + let mut current := "" + for c in folded do + if asciiTerm c then current := current.push c + else + if !current.isEmpty then result := result ++ [current]; current := "" + if singleTerm c then result := result ++ [String.singleton c] + if !current.isEmpty then result := result ++ [current] + return result.eraseDups + +def anchor (term : String) : Bool := + term.toList.any fun c => "_/:.".contains c || SearchUnicode.isDigit c + +def scoreOrder (a b : Float × Nat) : Bool := + a.1 > b.1 || (a.1 == b.1 && a.2 ≤ b.2) + +def lexical (query : String) (candidates : List String) (anchors := false) : List Nat := Id.run do + let wanted := (terms query).filter fun term => !anchors || anchor term + let documents := candidates.map terms + let mut scores := [] + for (document, index) in documents.zipIdx do + let score := wanted.foldl (fun total term => + if document.contains term then + let count := (documents.filter (·.contains term)).length + total + log1p (documents.length.toFloat / count.toFloat) + else total) (0 : Float) + if score > 0 then scores := scores ++ [(score, index)] + return (scores.mergeSort scoreOrder).map (·.2) + +def fused (rankings : List (List Nat)) : List Nat := + let ids := rankings.flatten.eraseDups + let scores := ids.map fun id => + (rankings.foldl (fun score ranking => + match (ranking.zipIdx.find? (·.1 == id)) with + | some (_, rank) => score + 1 / (61 + rank).toFloat + | none => score) (0 : Float), id) + (scores.mergeSort scoreOrder).map (·.2) + +/-- All provider output goes through this constructor. It cannot invent an + index, duplicate an outcome, or exceed the selected context budget. -/ +def select (count limit : Nat) (anchors ranking : List Nat) : List Nat := + ((anchors ++ ranking).eraseDups.filter (· < count)).take limit + +theorem unique_indices (xs : List Nat) : xs.eraseDups.Nodup := by + match xs with + | [] => simp + | x :: tail => + rw [List.eraseDups_cons, List.nodup_cons] + constructor + · simp + · exact unique_indices (tail.filter fun y => !y == x) +termination_by xs.length +decreasing_by + have := List.length_filter_le (fun y => !y == x) tail + simp only [List.length_cons] + omega + +theorem selected_no_duplicates (n k : Nat) (a r : List Nat) : (select n k a r).Nodup := by + exact (List.take_sublist k _).nodup (List.filter_sublist.nodup (unique_indices (a ++ r))) + +theorem selected_within_budget (n k : Nat) (a r : List Nat) : + (select n k a r).length ≤ k := by simp [select, List.length_take, Nat.min_le_left] + +theorem selected_is_existing (n k : Nat) (a r : List Nat) (id : Nat) + (member : id ∈ select n k a r) : id < n := by + have h := List.mem_of_mem_take member + have filtered := List.mem_filter.mp h + simpa using filtered.2 + +theorem selected_was_ranked (n k : Nat) (a r : List Nat) (id : Nat) + (member : id ∈ select n k a r) : id ∈ a ∨ id ∈ r := by + have h := List.mem_of_mem_take member + have filtered := (List.mem_filter.mp h).1 + simpa using filtered + +theorem zero_budget_is_empty (n : Nat) (a r : List Nat) : select n 0 a r = [] := rfl + +def windows (text : String) : List String := + let characters := text.toList + (List.range ((max 1 characters.length + 383) / 384)).map fun index => + String.ofList ((characters.drop (index * 384)).take 512) + +end Eggshell.SearchRank diff --git a/Eggshell/SearchUnicode.lean b/Eggshell/SearchUnicode.lean new file mode 100644 index 0000000..bec80e6 --- /dev/null +++ b/Eggshell/SearchUnicode.lean @@ -0,0 +1,1660 @@ +module + +public import Std + +@[expose] public section + +namespace Eggshell.SearchUnicode + +/-- Frozen Unicode 16.0.0 mappings from the previous provider. -/ +def foldBlock0 : Array (Nat × String) := #[ + (65, "a"), + (66, "b"), + (67, "c"), + (68, "d"), + (69, "e"), + (70, "f"), + (71, "g"), + (72, "h"), + (73, "i"), + (74, "j"), + (75, "k"), + (76, "l"), + (77, "m"), + (78, "n"), + (79, "o"), + (80, "p"), + (81, "q"), + (82, "r"), + (83, "s"), + (84, "t"), + (85, "u"), + (86, "v"), + (87, "w"), + (88, "x"), + (89, "y"), + (90, "z"), + (181, "μ"), + (192, "à"), + (193, "á"), + (194, "â"), + (195, "ã"), + (196, "ä"), + (197, "å"), + (198, "æ"), + (199, "ç"), + (200, "è"), + (201, "é"), + (202, "ê"), + (203, "ë"), + (204, "ì"), + (205, "í"), + (206, "î"), + (207, "ï"), + (208, "ð"), + (209, "ñ"), + (210, "ò"), + (211, "ó"), + (212, "ô"), + (213, "õ"), + (214, "ö"), + (216, "ø"), + (217, "ù"), + (218, "ú"), + (219, "û"), + (220, "ü"), + (221, "ý"), + (222, "þ"), + (223, "ss"), + (256, "ā"), + (258, "ă"), + (260, "ą"), + (262, "ć"), + (264, "ĉ"), + (266, "ċ") +] + +def foldBlock1 : Array (Nat × String) := #[ + (268, "č"), + (270, "ď"), + (272, "đ"), + (274, "ē"), + (276, "ĕ"), + (278, "ė"), + (280, "ę"), + (282, "ě"), + (284, "ĝ"), + (286, "ğ"), + (288, "ġ"), + (290, "ģ"), + (292, "ĥ"), + (294, "ħ"), + (296, "ĩ"), + (298, "ī"), + (300, "ĭ"), + (302, "į"), + (304, "i̇"), + (306, "ij"), + (308, "ĵ"), + (310, "ķ"), + (313, "ĺ"), + (315, "ļ"), + (317, "ľ"), + (319, "ŀ"), + (321, "ł"), + (323, "ń"), + (325, "ņ"), + (327, "ň"), + (329, "ʼn"), + (330, "ŋ"), + (332, "ō"), + (334, "ŏ"), + (336, "ő"), + (338, "œ"), + (340, "ŕ"), + (342, "ŗ"), + (344, "ř"), + (346, "ś"), + (348, "ŝ"), + (350, "ş"), + (352, "š"), + (354, "ţ"), + (356, "ť"), + (358, "ŧ"), + (360, "ũ"), + (362, "ū"), + (364, "ŭ"), + (366, "ů"), + (368, "ű"), + (370, "ų"), + (372, "ŵ"), + (374, "ŷ"), + (376, "ÿ"), + (377, "ź"), + (379, "ż"), + (381, "ž"), + (383, "s"), + (385, "ɓ"), + (386, "ƃ"), + (388, "ƅ"), + (390, "ɔ"), + (391, "ƈ") +] + +def foldBlock2 : Array (Nat × String) := #[ + (393, "ɖ"), + (394, "ɗ"), + (395, "ƌ"), + (398, "ǝ"), + (399, "ə"), + (400, "ɛ"), + (401, "ƒ"), + (403, "ɠ"), + (404, "ɣ"), + (406, "ɩ"), + (407, "ɨ"), + (408, "ƙ"), + (412, "ɯ"), + (413, "ɲ"), + (415, "ɵ"), + (416, "ơ"), + (418, "ƣ"), + (420, "ƥ"), + (422, "ʀ"), + (423, "ƨ"), + (425, "ʃ"), + (428, "ƭ"), + (430, "ʈ"), + (431, "ư"), + (433, "ʊ"), + (434, "ʋ"), + (435, "ƴ"), + (437, "ƶ"), + (439, "ʒ"), + (440, "ƹ"), + (444, "ƽ"), + (452, "dž"), + (453, "dž"), + (455, "lj"), + (456, "lj"), + (458, "nj"), + (459, "nj"), + (461, "ǎ"), + (463, "ǐ"), + (465, "ǒ"), + (467, "ǔ"), + (469, "ǖ"), + (471, "ǘ"), + (473, "ǚ"), + (475, "ǜ"), + (478, "ǟ"), + (480, "ǡ"), + (482, "ǣ"), + (484, "ǥ"), + (486, "ǧ"), + (488, "ǩ"), + (490, "ǫ"), + (492, "ǭ"), + (494, "ǯ"), + (496, "ǰ"), + (497, "dz"), + (498, "dz"), + (500, "ǵ"), + (502, "ƕ"), + (503, "ƿ"), + (504, "ǹ"), + (506, "ǻ"), + (508, "ǽ"), + (510, "ǿ") +] + +def foldBlock3 : Array (Nat × String) := #[ + (512, "ȁ"), + (514, "ȃ"), + (516, "ȅ"), + (518, "ȇ"), + (520, "ȉ"), + (522, "ȋ"), + (524, "ȍ"), + (526, "ȏ"), + (528, "ȑ"), + (530, "ȓ"), + (532, "ȕ"), + (534, "ȗ"), + (536, "ș"), + (538, "ț"), + (540, "ȝ"), + (542, "ȟ"), + (544, "ƞ"), + (546, "ȣ"), + (548, "ȥ"), + (550, "ȧ"), + (552, "ȩ"), + (554, "ȫ"), + (556, "ȭ"), + (558, "ȯ"), + (560, "ȱ"), + (562, "ȳ"), + (570, "ⱥ"), + (571, "ȼ"), + (573, "ƚ"), + (574, "ⱦ"), + (577, "ɂ"), + (579, "ƀ"), + (580, "ʉ"), + (581, "ʌ"), + (582, "ɇ"), + (584, "ɉ"), + (586, "ɋ"), + (588, "ɍ"), + (590, "ɏ"), + (837, "ι"), + (880, "ͱ"), + (882, "ͳ"), + (886, "ͷ"), + (895, "ϳ"), + (902, "ά"), + (904, "έ"), + (905, "ή"), + (906, "ί"), + (908, "ό"), + (910, "ύ"), + (911, "ώ"), + (912, "ΐ"), + (913, "α"), + (914, "β"), + (915, "γ"), + (916, "δ"), + (917, "ε"), + (918, "ζ"), + (919, "η"), + (920, "θ"), + (921, "ι"), + (922, "κ"), + (923, "λ"), + (924, "μ") +] + +def foldBlock4 : Array (Nat × String) := #[ + (925, "ν"), + (926, "ξ"), + (927, "ο"), + (928, "π"), + (929, "ρ"), + (931, "σ"), + (932, "τ"), + (933, "υ"), + (934, "φ"), + (935, "χ"), + (936, "ψ"), + (937, "ω"), + (938, "ϊ"), + (939, "ϋ"), + (944, "ΰ"), + (962, "σ"), + (975, "ϗ"), + (976, "β"), + (977, "θ"), + (981, "φ"), + (982, "π"), + (984, "ϙ"), + (986, "ϛ"), + (988, "ϝ"), + (990, "ϟ"), + (992, "ϡ"), + (994, "ϣ"), + (996, "ϥ"), + (998, "ϧ"), + (1000, "ϩ"), + (1002, "ϫ"), + (1004, "ϭ"), + (1006, "ϯ"), + (1008, "κ"), + (1009, "ρ"), + (1012, "θ"), + (1013, "ε"), + (1015, "ϸ"), + (1017, "ϲ"), + (1018, "ϻ"), + (1021, "ͻ"), + (1022, "ͼ"), + (1023, "ͽ"), + (1024, "ѐ"), + (1025, "ё"), + (1026, "ђ"), + (1027, "ѓ"), + (1028, "є"), + (1029, "ѕ"), + (1030, "і"), + (1031, "ї"), + (1032, "ј"), + (1033, "љ"), + (1034, "њ"), + (1035, "ћ"), + (1036, "ќ"), + (1037, "ѝ"), + (1038, "ў"), + (1039, "џ"), + (1040, "а"), + (1041, "б"), + (1042, "в"), + (1043, "г"), + (1044, "д") +] + +def foldBlock5 : Array (Nat × String) := #[ + (1045, "е"), + (1046, "ж"), + (1047, "з"), + (1048, "и"), + (1049, "й"), + (1050, "к"), + (1051, "л"), + (1052, "м"), + (1053, "н"), + (1054, "о"), + (1055, "п"), + (1056, "р"), + (1057, "с"), + (1058, "т"), + (1059, "у"), + (1060, "ф"), + (1061, "х"), + (1062, "ц"), + (1063, "ч"), + (1064, "ш"), + (1065, "щ"), + (1066, "ъ"), + (1067, "ы"), + (1068, "ь"), + (1069, "э"), + (1070, "ю"), + (1071, "я"), + (1120, "ѡ"), + (1122, "ѣ"), + (1124, "ѥ"), + (1126, "ѧ"), + (1128, "ѩ"), + (1130, "ѫ"), + (1132, "ѭ"), + (1134, "ѯ"), + (1136, "ѱ"), + (1138, "ѳ"), + (1140, "ѵ"), + (1142, "ѷ"), + (1144, "ѹ"), + (1146, "ѻ"), + (1148, "ѽ"), + (1150, "ѿ"), + (1152, "ҁ"), + (1162, "ҋ"), + (1164, "ҍ"), + (1166, "ҏ"), + (1168, "ґ"), + (1170, "ғ"), + (1172, "ҕ"), + (1174, "җ"), + (1176, "ҙ"), + (1178, "қ"), + (1180, "ҝ"), + (1182, "ҟ"), + (1184, "ҡ"), + (1186, "ң"), + (1188, "ҥ"), + (1190, "ҧ"), + (1192, "ҩ"), + (1194, "ҫ"), + (1196, "ҭ"), + (1198, "ү"), + (1200, "ұ") +] + +def foldBlock6 : Array (Nat × String) := #[ + (1202, "ҳ"), + (1204, "ҵ"), + (1206, "ҷ"), + (1208, "ҹ"), + (1210, "һ"), + (1212, "ҽ"), + (1214, "ҿ"), + (1216, "ӏ"), + (1217, "ӂ"), + (1219, "ӄ"), + (1221, "ӆ"), + (1223, "ӈ"), + (1225, "ӊ"), + (1227, "ӌ"), + (1229, "ӎ"), + (1232, "ӑ"), + (1234, "ӓ"), + (1236, "ӕ"), + (1238, "ӗ"), + (1240, "ә"), + (1242, "ӛ"), + (1244, "ӝ"), + (1246, "ӟ"), + (1248, "ӡ"), + (1250, "ӣ"), + (1252, "ӥ"), + (1254, "ӧ"), + (1256, "ө"), + (1258, "ӫ"), + (1260, "ӭ"), + (1262, "ӯ"), + (1264, "ӱ"), + (1266, "ӳ"), + (1268, "ӵ"), + (1270, "ӷ"), + (1272, "ӹ"), + (1274, "ӻ"), + (1276, "ӽ"), + (1278, "ӿ"), + (1280, "ԁ"), + (1282, "ԃ"), + (1284, "ԅ"), + (1286, "ԇ"), + (1288, "ԉ"), + (1290, "ԋ"), + (1292, "ԍ"), + (1294, "ԏ"), + (1296, "ԑ"), + (1298, "ԓ"), + (1300, "ԕ"), + (1302, "ԗ"), + (1304, "ԙ"), + (1306, "ԛ"), + (1308, "ԝ"), + (1310, "ԟ"), + (1312, "ԡ"), + (1314, "ԣ"), + (1316, "ԥ"), + (1318, "ԧ"), + (1320, "ԩ"), + (1322, "ԫ"), + (1324, "ԭ"), + (1326, "ԯ"), + (1329, "ա") +] + +def foldBlock7 : Array (Nat × String) := #[ + (1330, "բ"), + (1331, "գ"), + (1332, "դ"), + (1333, "ե"), + (1334, "զ"), + (1335, "է"), + (1336, "ը"), + (1337, "թ"), + (1338, "ժ"), + (1339, "ի"), + (1340, "լ"), + (1341, "խ"), + (1342, "ծ"), + (1343, "կ"), + (1344, "հ"), + (1345, "ձ"), + (1346, "ղ"), + (1347, "ճ"), + (1348, "մ"), + (1349, "յ"), + (1350, "ն"), + (1351, "շ"), + (1352, "ո"), + (1353, "չ"), + (1354, "պ"), + (1355, "ջ"), + (1356, "ռ"), + (1357, "ս"), + (1358, "վ"), + (1359, "տ"), + (1360, "ր"), + (1361, "ց"), + (1362, "ւ"), + (1363, "փ"), + (1364, "ք"), + (1365, "օ"), + (1366, "ֆ"), + (1415, "եւ"), + (4256, "ⴀ"), + (4257, "ⴁ"), + (4258, "ⴂ"), + (4259, "ⴃ"), + (4260, "ⴄ"), + (4261, "ⴅ"), + (4262, "ⴆ"), + (4263, "ⴇ"), + (4264, "ⴈ"), + (4265, "ⴉ"), + (4266, "ⴊ"), + (4267, "ⴋ"), + (4268, "ⴌ"), + (4269, "ⴍ"), + (4270, "ⴎ"), + (4271, "ⴏ"), + (4272, "ⴐ"), + (4273, "ⴑ"), + (4274, "ⴒ"), + (4275, "ⴓ"), + (4276, "ⴔ"), + (4277, "ⴕ"), + (4278, "ⴖ"), + (4279, "ⴗ"), + (4280, "ⴘ"), + (4281, "ⴙ") +] + +def foldBlock8 : Array (Nat × String) := #[ + (4282, "ⴚ"), + (4283, "ⴛ"), + (4284, "ⴜ"), + (4285, "ⴝ"), + (4286, "ⴞ"), + (4287, "ⴟ"), + (4288, "ⴠ"), + (4289, "ⴡ"), + (4290, "ⴢ"), + (4291, "ⴣ"), + (4292, "ⴤ"), + (4293, "ⴥ"), + (4295, "ⴧ"), + (4301, "ⴭ"), + (5112, "Ᏸ"), + (5113, "Ᏹ"), + (5114, "Ᏺ"), + (5115, "Ᏻ"), + (5116, "Ᏼ"), + (5117, "Ᏽ"), + (7296, "в"), + (7297, "д"), + (7298, "о"), + (7299, "с"), + (7300, "т"), + (7301, "т"), + (7302, "ъ"), + (7303, "ѣ"), + (7304, "ꙋ"), + (7305, "ᲊ"), + (7312, "ა"), + (7313, "ბ"), + (7314, "გ"), + (7315, "დ"), + (7316, "ე"), + (7317, "ვ"), + (7318, "ზ"), + (7319, "თ"), + (7320, "ი"), + (7321, "კ"), + (7322, "ლ"), + (7323, "მ"), + (7324, "ნ"), + (7325, "ო"), + (7326, "პ"), + (7327, "ჟ"), + (7328, "რ"), + (7329, "ს"), + (7330, "ტ"), + (7331, "უ"), + (7332, "ფ"), + (7333, "ქ"), + (7334, "ღ"), + (7335, "ყ"), + (7336, "შ"), + (7337, "ჩ"), + (7338, "ც"), + (7339, "ძ"), + (7340, "წ"), + (7341, "ჭ"), + (7342, "ხ"), + (7343, "ჯ"), + (7344, "ჰ"), + (7345, "ჱ") +] + +def foldBlock9 : Array (Nat × String) := #[ + (7346, "ჲ"), + (7347, "ჳ"), + (7348, "ჴ"), + (7349, "ჵ"), + (7350, "ჶ"), + (7351, "ჷ"), + (7352, "ჸ"), + (7353, "ჹ"), + (7354, "ჺ"), + (7357, "ჽ"), + (7358, "ჾ"), + (7359, "ჿ"), + (7680, "ḁ"), + (7682, "ḃ"), + (7684, "ḅ"), + (7686, "ḇ"), + (7688, "ḉ"), + (7690, "ḋ"), + (7692, "ḍ"), + (7694, "ḏ"), + (7696, "ḑ"), + (7698, "ḓ"), + (7700, "ḕ"), + (7702, "ḗ"), + (7704, "ḙ"), + (7706, "ḛ"), + (7708, "ḝ"), + (7710, "ḟ"), + (7712, "ḡ"), + (7714, "ḣ"), + (7716, "ḥ"), + (7718, "ḧ"), + (7720, "ḩ"), + (7722, "ḫ"), + (7724, "ḭ"), + (7726, "ḯ"), + (7728, "ḱ"), + (7730, "ḳ"), + (7732, "ḵ"), + (7734, "ḷ"), + (7736, "ḹ"), + (7738, "ḻ"), + (7740, "ḽ"), + (7742, "ḿ"), + (7744, "ṁ"), + (7746, "ṃ"), + (7748, "ṅ"), + (7750, "ṇ"), + (7752, "ṉ"), + (7754, "ṋ"), + (7756, "ṍ"), + (7758, "ṏ"), + (7760, "ṑ"), + (7762, "ṓ"), + (7764, "ṕ"), + (7766, "ṗ"), + (7768, "ṙ"), + (7770, "ṛ"), + (7772, "ṝ"), + (7774, "ṟ"), + (7776, "ṡ"), + (7778, "ṣ"), + (7780, "ṥ"), + (7782, "ṧ") +] + +def foldBlock10 : Array (Nat × String) := #[ + (7784, "ṩ"), + (7786, "ṫ"), + (7788, "ṭ"), + (7790, "ṯ"), + (7792, "ṱ"), + (7794, "ṳ"), + (7796, "ṵ"), + (7798, "ṷ"), + (7800, "ṹ"), + (7802, "ṻ"), + (7804, "ṽ"), + (7806, "ṿ"), + (7808, "ẁ"), + (7810, "ẃ"), + (7812, "ẅ"), + (7814, "ẇ"), + (7816, "ẉ"), + (7818, "ẋ"), + (7820, "ẍ"), + (7822, "ẏ"), + (7824, "ẑ"), + (7826, "ẓ"), + (7828, "ẕ"), + (7830, "ẖ"), + (7831, "ẗ"), + (7832, "ẘ"), + (7833, "ẙ"), + (7834, "aʾ"), + (7835, "ṡ"), + (7838, "ss"), + (7840, "ạ"), + (7842, "ả"), + (7844, "ấ"), + (7846, "ầ"), + (7848, "ẩ"), + (7850, "ẫ"), + (7852, "ậ"), + (7854, "ắ"), + (7856, "ằ"), + (7858, "ẳ"), + (7860, "ẵ"), + (7862, "ặ"), + (7864, "ẹ"), + (7866, "ẻ"), + (7868, "ẽ"), + (7870, "ế"), + (7872, "ề"), + (7874, "ể"), + (7876, "ễ"), + (7878, "ệ"), + (7880, "ỉ"), + (7882, "ị"), + (7884, "ọ"), + (7886, "ỏ"), + (7888, "ố"), + (7890, "ồ"), + (7892, "ổ"), + (7894, "ỗ"), + (7896, "ộ"), + (7898, "ớ"), + (7900, "ờ"), + (7902, "ở"), + (7904, "ỡ"), + (7906, "ợ") +] + +def foldBlock11 : Array (Nat × String) := #[ + (7908, "ụ"), + (7910, "ủ"), + (7912, "ứ"), + (7914, "ừ"), + (7916, "ử"), + (7918, "ữ"), + (7920, "ự"), + (7922, "ỳ"), + (7924, "ỵ"), + (7926, "ỷ"), + (7928, "ỹ"), + (7930, "ỻ"), + (7932, "ỽ"), + (7934, "ỿ"), + (7944, "ἀ"), + (7945, "ἁ"), + (7946, "ἂ"), + (7947, "ἃ"), + (7948, "ἄ"), + (7949, "ἅ"), + (7950, "ἆ"), + (7951, "ἇ"), + (7960, "ἐ"), + (7961, "ἑ"), + (7962, "ἒ"), + (7963, "ἓ"), + (7964, "ἔ"), + (7965, "ἕ"), + (7976, "ἠ"), + (7977, "ἡ"), + (7978, "ἢ"), + (7979, "ἣ"), + (7980, "ἤ"), + (7981, "ἥ"), + (7982, "ἦ"), + (7983, "ἧ"), + (7992, "ἰ"), + (7993, "ἱ"), + (7994, "ἲ"), + (7995, "ἳ"), + (7996, "ἴ"), + (7997, "ἵ"), + (7998, "ἶ"), + (7999, "ἷ"), + (8008, "ὀ"), + (8009, "ὁ"), + (8010, "ὂ"), + (8011, "ὃ"), + (8012, "ὄ"), + (8013, "ὅ"), + (8016, "ὐ"), + (8018, "ὒ"), + (8020, "ὔ"), + (8022, "ὖ"), + (8025, "ὑ"), + (8027, "ὓ"), + (8029, "ὕ"), + (8031, "ὗ"), + (8040, "ὠ"), + (8041, "ὡ"), + (8042, "ὢ"), + (8043, "ὣ"), + (8044, "ὤ"), + (8045, "ὥ") +] + +def foldBlock12 : Array (Nat × String) := #[ + (8046, "ὦ"), + (8047, "ὧ"), + (8064, "ἀι"), + (8065, "ἁι"), + (8066, "ἂι"), + (8067, "ἃι"), + (8068, "ἄι"), + (8069, "ἅι"), + (8070, "ἆι"), + (8071, "ἇι"), + (8072, "ἀι"), + (8073, "ἁι"), + (8074, "ἂι"), + (8075, "ἃι"), + (8076, "ἄι"), + (8077, "ἅι"), + (8078, "ἆι"), + (8079, "ἇι"), + (8080, "ἠι"), + (8081, "ἡι"), + (8082, "ἢι"), + (8083, "ἣι"), + (8084, "ἤι"), + (8085, "ἥι"), + (8086, "ἦι"), + (8087, "ἧι"), + (8088, "ἠι"), + (8089, "ἡι"), + (8090, "ἢι"), + (8091, "ἣι"), + (8092, "ἤι"), + (8093, "ἥι"), + (8094, "ἦι"), + (8095, "ἧι"), + (8096, "ὠι"), + (8097, "ὡι"), + (8098, "ὢι"), + (8099, "ὣι"), + (8100, "ὤι"), + (8101, "ὥι"), + (8102, "ὦι"), + (8103, "ὧι"), + (8104, "ὠι"), + (8105, "ὡι"), + (8106, "ὢι"), + (8107, "ὣι"), + (8108, "ὤι"), + (8109, "ὥι"), + (8110, "ὦι"), + (8111, "ὧι"), + (8114, "ὰι"), + (8115, "αι"), + (8116, "άι"), + (8118, "ᾶ"), + (8119, "ᾶι"), + (8120, "ᾰ"), + (8121, "ᾱ"), + (8122, "ὰ"), + (8123, "ά"), + (8124, "αι"), + (8126, "ι"), + (8130, "ὴι"), + (8131, "ηι"), + (8132, "ήι") +] + +def foldBlock13 : Array (Nat × String) := #[ + (8134, "ῆ"), + (8135, "ῆι"), + (8136, "ὲ"), + (8137, "έ"), + (8138, "ὴ"), + (8139, "ή"), + (8140, "ηι"), + (8146, "ῒ"), + (8147, "ΐ"), + (8150, "ῖ"), + (8151, "ῗ"), + (8152, "ῐ"), + (8153, "ῑ"), + (8154, "ὶ"), + (8155, "ί"), + (8162, "ῢ"), + (8163, "ΰ"), + (8164, "ῤ"), + (8166, "ῦ"), + (8167, "ῧ"), + (8168, "ῠ"), + (8169, "ῡ"), + (8170, "ὺ"), + (8171, "ύ"), + (8172, "ῥ"), + (8178, "ὼι"), + (8179, "ωι"), + (8180, "ώι"), + (8182, "ῶ"), + (8183, "ῶι"), + (8184, "ὸ"), + (8185, "ό"), + (8186, "ὼ"), + (8187, "ώ"), + (8188, "ωι"), + (8486, "ω"), + (8490, "k"), + (8491, "å"), + (8498, "ⅎ"), + (8544, "ⅰ"), + (8545, "ⅱ"), + (8546, "ⅲ"), + (8547, "ⅳ"), + (8548, "ⅴ"), + (8549, "ⅵ"), + (8550, "ⅶ"), + (8551, "ⅷ"), + (8552, "ⅸ"), + (8553, "ⅹ"), + (8554, "ⅺ"), + (8555, "ⅻ"), + (8556, "ⅼ"), + (8557, "ⅽ"), + (8558, "ⅾ"), + (8559, "ⅿ"), + (8579, "ↄ"), + (9398, "ⓐ"), + (9399, "ⓑ"), + (9400, "ⓒ"), + (9401, "ⓓ"), + (9402, "ⓔ"), + (9403, "ⓕ"), + (9404, "ⓖ"), + (9405, "ⓗ") +] + +def foldBlock14 : Array (Nat × String) := #[ + (9406, "ⓘ"), + (9407, "ⓙ"), + (9408, "ⓚ"), + (9409, "ⓛ"), + (9410, "ⓜ"), + (9411, "ⓝ"), + (9412, "ⓞ"), + (9413, "ⓟ"), + (9414, "ⓠ"), + (9415, "ⓡ"), + (9416, "ⓢ"), + (9417, "ⓣ"), + (9418, "ⓤ"), + (9419, "ⓥ"), + (9420, "ⓦ"), + (9421, "ⓧ"), + (9422, "ⓨ"), + (9423, "ⓩ"), + (11264, "ⰰ"), + (11265, "ⰱ"), + (11266, "ⰲ"), + (11267, "ⰳ"), + (11268, "ⰴ"), + (11269, "ⰵ"), + (11270, "ⰶ"), + (11271, "ⰷ"), + (11272, "ⰸ"), + (11273, "ⰹ"), + (11274, "ⰺ"), + (11275, "ⰻ"), + (11276, "ⰼ"), + (11277, "ⰽ"), + (11278, "ⰾ"), + (11279, "ⰿ"), + (11280, "ⱀ"), + (11281, "ⱁ"), + (11282, "ⱂ"), + (11283, "ⱃ"), + (11284, "ⱄ"), + (11285, "ⱅ"), + (11286, "ⱆ"), + (11287, "ⱇ"), + (11288, "ⱈ"), + (11289, "ⱉ"), + (11290, "ⱊ"), + (11291, "ⱋ"), + (11292, "ⱌ"), + (11293, "ⱍ"), + (11294, "ⱎ"), + (11295, "ⱏ"), + (11296, "ⱐ"), + (11297, "ⱑ"), + (11298, "ⱒ"), + (11299, "ⱓ"), + (11300, "ⱔ"), + (11301, "ⱕ"), + (11302, "ⱖ"), + (11303, "ⱗ"), + (11304, "ⱘ"), + (11305, "ⱙ"), + (11306, "ⱚ"), + (11307, "ⱛ"), + (11308, "ⱜ"), + (11309, "ⱝ") +] + +def foldBlock15 : Array (Nat × String) := #[ + (11310, "ⱞ"), + (11311, "ⱟ"), + (11360, "ⱡ"), + (11362, "ɫ"), + (11363, "ᵽ"), + (11364, "ɽ"), + (11367, "ⱨ"), + (11369, "ⱪ"), + (11371, "ⱬ"), + (11373, "ɑ"), + (11374, "ɱ"), + (11375, "ɐ"), + (11376, "ɒ"), + (11378, "ⱳ"), + (11381, "ⱶ"), + (11390, "ȿ"), + (11391, "ɀ"), + (11392, "ⲁ"), + (11394, "ⲃ"), + (11396, "ⲅ"), + (11398, "ⲇ"), + (11400, "ⲉ"), + (11402, "ⲋ"), + (11404, "ⲍ"), + (11406, "ⲏ"), + (11408, "ⲑ"), + (11410, "ⲓ"), + (11412, "ⲕ"), + (11414, "ⲗ"), + (11416, "ⲙ"), + (11418, "ⲛ"), + (11420, "ⲝ"), + (11422, "ⲟ"), + (11424, "ⲡ"), + (11426, "ⲣ"), + (11428, "ⲥ"), + (11430, "ⲧ"), + (11432, "ⲩ"), + (11434, "ⲫ"), + (11436, "ⲭ"), + (11438, "ⲯ"), + (11440, "ⲱ"), + (11442, "ⲳ"), + (11444, "ⲵ"), + (11446, "ⲷ"), + (11448, "ⲹ"), + (11450, "ⲻ"), + (11452, "ⲽ"), + (11454, "ⲿ"), + (11456, "ⳁ"), + (11458, "ⳃ"), + (11460, "ⳅ"), + (11462, "ⳇ"), + (11464, "ⳉ"), + (11466, "ⳋ"), + (11468, "ⳍ"), + (11470, "ⳏ"), + (11472, "ⳑ"), + (11474, "ⳓ"), + (11476, "ⳕ"), + (11478, "ⳗ"), + (11480, "ⳙ"), + (11482, "ⳛ"), + (11484, "ⳝ") +] + +def foldBlock16 : Array (Nat × String) := #[ + (11486, "ⳟ"), + (11488, "ⳡ"), + (11490, "ⳣ"), + (11499, "ⳬ"), + (11501, "ⳮ"), + (11506, "ⳳ"), + (42560, "ꙁ"), + (42562, "ꙃ"), + (42564, "ꙅ"), + (42566, "ꙇ"), + (42568, "ꙉ"), + (42570, "ꙋ"), + (42572, "ꙍ"), + (42574, "ꙏ"), + (42576, "ꙑ"), + (42578, "ꙓ"), + (42580, "ꙕ"), + (42582, "ꙗ"), + (42584, "ꙙ"), + (42586, "ꙛ"), + (42588, "ꙝ"), + (42590, "ꙟ"), + (42592, "ꙡ"), + (42594, "ꙣ"), + (42596, "ꙥ"), + (42598, "ꙧ"), + (42600, "ꙩ"), + (42602, "ꙫ"), + (42604, "ꙭ"), + (42624, "ꚁ"), + (42626, "ꚃ"), + (42628, "ꚅ"), + (42630, "ꚇ"), + (42632, "ꚉ"), + (42634, "ꚋ"), + (42636, "ꚍ"), + (42638, "ꚏ"), + (42640, "ꚑ"), + (42642, "ꚓ"), + (42644, "ꚕ"), + (42646, "ꚗ"), + (42648, "ꚙ"), + (42650, "ꚛ"), + (42786, "ꜣ"), + (42788, "ꜥ"), + (42790, "ꜧ"), + (42792, "ꜩ"), + (42794, "ꜫ"), + (42796, "ꜭ"), + (42798, "ꜯ"), + (42802, "ꜳ"), + (42804, "ꜵ"), + (42806, "ꜷ"), + (42808, "ꜹ"), + (42810, "ꜻ"), + (42812, "ꜽ"), + (42814, "ꜿ"), + (42816, "ꝁ"), + (42818, "ꝃ"), + (42820, "ꝅ"), + (42822, "ꝇ"), + (42824, "ꝉ"), + (42826, "ꝋ"), + (42828, "ꝍ") +] + +def foldBlock17 : Array (Nat × String) := #[ + (42830, "ꝏ"), + (42832, "ꝑ"), + (42834, "ꝓ"), + (42836, "ꝕ"), + (42838, "ꝗ"), + (42840, "ꝙ"), + (42842, "ꝛ"), + (42844, "ꝝ"), + (42846, "ꝟ"), + (42848, "ꝡ"), + (42850, "ꝣ"), + (42852, "ꝥ"), + (42854, "ꝧ"), + (42856, "ꝩ"), + (42858, "ꝫ"), + (42860, "ꝭ"), + (42862, "ꝯ"), + (42873, "ꝺ"), + (42875, "ꝼ"), + (42877, "ᵹ"), + (42878, "ꝿ"), + (42880, "ꞁ"), + (42882, "ꞃ"), + (42884, "ꞅ"), + (42886, "ꞇ"), + (42891, "ꞌ"), + (42893, "ɥ"), + (42896, "ꞑ"), + (42898, "ꞓ"), + (42902, "ꞗ"), + (42904, "ꞙ"), + (42906, "ꞛ"), + (42908, "ꞝ"), + (42910, "ꞟ"), + (42912, "ꞡ"), + (42914, "ꞣ"), + (42916, "ꞥ"), + (42918, "ꞧ"), + (42920, "ꞩ"), + (42922, "ɦ"), + (42923, "ɜ"), + (42924, "ɡ"), + (42925, "ɬ"), + (42926, "ɪ"), + (42928, "ʞ"), + (42929, "ʇ"), + (42930, "ʝ"), + (42931, "ꭓ"), + (42932, "ꞵ"), + (42934, "ꞷ"), + (42936, "ꞹ"), + (42938, "ꞻ"), + (42940, "ꞽ"), + (42942, "ꞿ"), + (42944, "ꟁ"), + (42946, "ꟃ"), + (42948, "ꞔ"), + (42949, "ʂ"), + (42950, "ᶎ"), + (42951, "ꟈ"), + (42953, "ꟊ"), + (42955, "ɤ"), + (42956, "ꟍ"), + (42960, "ꟑ") +] + +def foldBlock18 : Array (Nat × String) := #[ + (42966, "ꟗ"), + (42968, "ꟙ"), + (42970, "ꟛ"), + (42972, "ƛ"), + (42997, "ꟶ"), + (43888, "Ꭰ"), + (43889, "Ꭱ"), + (43890, "Ꭲ"), + (43891, "Ꭳ"), + (43892, "Ꭴ"), + (43893, "Ꭵ"), + (43894, "Ꭶ"), + (43895, "Ꭷ"), + (43896, "Ꭸ"), + (43897, "Ꭹ"), + (43898, "Ꭺ"), + (43899, "Ꭻ"), + (43900, "Ꭼ"), + (43901, "Ꭽ"), + (43902, "Ꭾ"), + (43903, "Ꭿ"), + (43904, "Ꮀ"), + (43905, "Ꮁ"), + (43906, "Ꮂ"), + (43907, "Ꮃ"), + (43908, "Ꮄ"), + (43909, "Ꮅ"), + (43910, "Ꮆ"), + (43911, "Ꮇ"), + (43912, "Ꮈ"), + (43913, "Ꮉ"), + (43914, "Ꮊ"), + (43915, "Ꮋ"), + (43916, "Ꮌ"), + (43917, "Ꮍ"), + (43918, "Ꮎ"), + (43919, "Ꮏ"), + (43920, "Ꮐ"), + (43921, "Ꮑ"), + (43922, "Ꮒ"), + (43923, "Ꮓ"), + (43924, "Ꮔ"), + (43925, "Ꮕ"), + (43926, "Ꮖ"), + (43927, "Ꮗ"), + (43928, "Ꮘ"), + (43929, "Ꮙ"), + (43930, "Ꮚ"), + (43931, "Ꮛ"), + (43932, "Ꮜ"), + (43933, "Ꮝ"), + (43934, "Ꮞ"), + (43935, "Ꮟ"), + (43936, "Ꮠ"), + (43937, "Ꮡ"), + (43938, "Ꮢ"), + (43939, "Ꮣ"), + (43940, "Ꮤ"), + (43941, "Ꮥ"), + (43942, "Ꮦ"), + (43943, "Ꮧ"), + (43944, "Ꮨ"), + (43945, "Ꮩ"), + (43946, "Ꮪ") +] + +def foldBlock19 : Array (Nat × String) := #[ + (43947, "Ꮫ"), + (43948, "Ꮬ"), + (43949, "Ꮭ"), + (43950, "Ꮮ"), + (43951, "Ꮯ"), + (43952, "Ꮰ"), + (43953, "Ꮱ"), + (43954, "Ꮲ"), + (43955, "Ꮳ"), + (43956, "Ꮴ"), + (43957, "Ꮵ"), + (43958, "Ꮶ"), + (43959, "Ꮷ"), + (43960, "Ꮸ"), + (43961, "Ꮹ"), + (43962, "Ꮺ"), + (43963, "Ꮻ"), + (43964, "Ꮼ"), + (43965, "Ꮽ"), + (43966, "Ꮾ"), + (43967, "Ꮿ"), + (64256, "ff"), + (64257, "fi"), + (64258, "fl"), + (64259, "ffi"), + (64260, "ffl"), + (64261, "st"), + (64262, "st"), + (64275, "մն"), + (64276, "մե"), + (64277, "մի"), + (64278, "վն"), + (64279, "մխ"), + (65313, "a"), + (65314, "b"), + (65315, "c"), + (65316, "d"), + (65317, "e"), + (65318, "f"), + (65319, "g"), + (65320, "h"), + (65321, "i"), + (65322, "j"), + (65323, "k"), + (65324, "l"), + (65325, "m"), + (65326, "n"), + (65327, "o"), + (65328, "p"), + (65329, "q"), + (65330, "r"), + (65331, "s"), + (65332, "t"), + (65333, "u"), + (65334, "v"), + (65335, "w"), + (65336, "x"), + (65337, "y"), + (65338, "z"), + (66560, "𐐨"), + (66561, "𐐩"), + (66562, "𐐪"), + (66563, "𐐫"), + (66564, "𐐬") +] + +def foldBlock20 : Array (Nat × String) := #[ + (66565, "𐐭"), + (66566, "𐐮"), + (66567, "𐐯"), + (66568, "𐐰"), + (66569, "𐐱"), + (66570, "𐐲"), + (66571, "𐐳"), + (66572, "𐐴"), + (66573, "𐐵"), + (66574, "𐐶"), + (66575, "𐐷"), + (66576, "𐐸"), + (66577, "𐐹"), + (66578, "𐐺"), + (66579, "𐐻"), + (66580, "𐐼"), + (66581, "𐐽"), + (66582, "𐐾"), + (66583, "𐐿"), + (66584, "𐑀"), + (66585, "𐑁"), + (66586, "𐑂"), + (66587, "𐑃"), + (66588, "𐑄"), + (66589, "𐑅"), + (66590, "𐑆"), + (66591, "𐑇"), + (66592, "𐑈"), + (66593, "𐑉"), + (66594, "𐑊"), + (66595, "𐑋"), + (66596, "𐑌"), + (66597, "𐑍"), + (66598, "𐑎"), + (66599, "𐑏"), + (66736, "𐓘"), + (66737, "𐓙"), + (66738, "𐓚"), + (66739, "𐓛"), + (66740, "𐓜"), + (66741, "𐓝"), + (66742, "𐓞"), + (66743, "𐓟"), + (66744, "𐓠"), + (66745, "𐓡"), + (66746, "𐓢"), + (66747, "𐓣"), + (66748, "𐓤"), + (66749, "𐓥"), + (66750, "𐓦"), + (66751, "𐓧"), + (66752, "𐓨"), + (66753, "𐓩"), + (66754, "𐓪"), + (66755, "𐓫"), + (66756, "𐓬"), + (66757, "𐓭"), + (66758, "𐓮"), + (66759, "𐓯"), + (66760, "𐓰"), + (66761, "𐓱"), + (66762, "𐓲"), + (66763, "𐓳"), + (66764, "𐓴") +] + +def foldBlock21 : Array (Nat × String) := #[ + (66765, "𐓵"), + (66766, "𐓶"), + (66767, "𐓷"), + (66768, "𐓸"), + (66769, "𐓹"), + (66770, "𐓺"), + (66771, "𐓻"), + (66928, "𐖗"), + (66929, "𐖘"), + (66930, "𐖙"), + (66931, "𐖚"), + (66932, "𐖛"), + (66933, "𐖜"), + (66934, "𐖝"), + (66935, "𐖞"), + (66936, "𐖟"), + (66937, "𐖠"), + (66938, "𐖡"), + (66940, "𐖣"), + (66941, "𐖤"), + (66942, "𐖥"), + (66943, "𐖦"), + (66944, "𐖧"), + (66945, "𐖨"), + (66946, "𐖩"), + (66947, "𐖪"), + (66948, "𐖫"), + (66949, "𐖬"), + (66950, "𐖭"), + (66951, "𐖮"), + (66952, "𐖯"), + (66953, "𐖰"), + (66954, "𐖱"), + (66956, "𐖳"), + (66957, "𐖴"), + (66958, "𐖵"), + (66959, "𐖶"), + (66960, "𐖷"), + (66961, "𐖸"), + (66962, "𐖹"), + (66964, "𐖻"), + (66965, "𐖼"), + (68736, "𐳀"), + (68737, "𐳁"), + (68738, "𐳂"), + (68739, "𐳃"), + (68740, "𐳄"), + (68741, "𐳅"), + (68742, "𐳆"), + (68743, "𐳇"), + (68744, "𐳈"), + (68745, "𐳉"), + (68746, "𐳊"), + (68747, "𐳋"), + (68748, "𐳌"), + (68749, "𐳍"), + (68750, "𐳎"), + (68751, "𐳏"), + (68752, "𐳐"), + (68753, "𐳑"), + (68754, "𐳒"), + (68755, "𐳓"), + (68756, "𐳔"), + (68757, "𐳕") +] + +def foldBlock22 : Array (Nat × String) := #[ + (68758, "𐳖"), + (68759, "𐳗"), + (68760, "𐳘"), + (68761, "𐳙"), + (68762, "𐳚"), + (68763, "𐳛"), + (68764, "𐳜"), + (68765, "𐳝"), + (68766, "𐳞"), + (68767, "𐳟"), + (68768, "𐳠"), + (68769, "𐳡"), + (68770, "𐳢"), + (68771, "𐳣"), + (68772, "𐳤"), + (68773, "𐳥"), + (68774, "𐳦"), + (68775, "𐳧"), + (68776, "𐳨"), + (68777, "𐳩"), + (68778, "𐳪"), + (68779, "𐳫"), + (68780, "𐳬"), + (68781, "𐳭"), + (68782, "𐳮"), + (68783, "𐳯"), + (68784, "𐳰"), + (68785, "𐳱"), + (68786, "𐳲"), + (68944, "𐵰"), + (68945, "𐵱"), + (68946, "𐵲"), + (68947, "𐵳"), + (68948, "𐵴"), + (68949, "𐵵"), + (68950, "𐵶"), + (68951, "𐵷"), + (68952, "𐵸"), + (68953, "𐵹"), + (68954, "𐵺"), + (68955, "𐵻"), + (68956, "𐵼"), + (68957, "𐵽"), + (68958, "𐵾"), + (68959, "𐵿"), + (68960, "𐶀"), + (68961, "𐶁"), + (68962, "𐶂"), + (68963, "𐶃"), + (68964, "𐶄"), + (68965, "𐶅"), + (71840, "𑣀"), + (71841, "𑣁"), + (71842, "𑣂"), + (71843, "𑣃"), + (71844, "𑣄"), + (71845, "𑣅"), + (71846, "𑣆"), + (71847, "𑣇"), + (71848, "𑣈"), + (71849, "𑣉"), + (71850, "𑣊"), + (71851, "𑣋"), + (71852, "𑣌") +] + +def foldBlock23 : Array (Nat × String) := #[ + (71853, "𑣍"), + (71854, "𑣎"), + (71855, "𑣏"), + (71856, "𑣐"), + (71857, "𑣑"), + (71858, "𑣒"), + (71859, "𑣓"), + (71860, "𑣔"), + (71861, "𑣕"), + (71862, "𑣖"), + (71863, "𑣗"), + (71864, "𑣘"), + (71865, "𑣙"), + (71866, "𑣚"), + (71867, "𑣛"), + (71868, "𑣜"), + (71869, "𑣝"), + (71870, "𑣞"), + (71871, "𑣟"), + (93760, "𖹠"), + (93761, "𖹡"), + (93762, "𖹢"), + (93763, "𖹣"), + (93764, "𖹤"), + (93765, "𖹥"), + (93766, "𖹦"), + (93767, "𖹧"), + (93768, "𖹨"), + (93769, "𖹩"), + (93770, "𖹪"), + (93771, "𖹫"), + (93772, "𖹬"), + (93773, "𖹭"), + (93774, "𖹮"), + (93775, "𖹯"), + (93776, "𖹰"), + (93777, "𖹱"), + (93778, "𖹲"), + (93779, "𖹳"), + (93780, "𖹴"), + (93781, "𖹵"), + (93782, "𖹶"), + (93783, "𖹷"), + (93784, "𖹸"), + (93785, "𖹹"), + (93786, "𖹺"), + (93787, "𖹻"), + (93788, "𖹼"), + (93789, "𖹽"), + (93790, "𖹾"), + (93791, "𖹿"), + (125184, "𞤢"), + (125185, "𞤣"), + (125186, "𞤤"), + (125187, "𞤥"), + (125188, "𞤦"), + (125189, "𞤧"), + (125190, "𞤨"), + (125191, "𞤩"), + (125192, "𞤪"), + (125193, "𞤫"), + (125194, "𞤬"), + (125195, "𞤭"), + (125196, "𞤮") +] + +def foldBlock24 : Array (Nat × String) := #[ + (125197, "𞤯"), + (125198, "𞤰"), + (125199, "𞤱"), + (125200, "𞤲"), + (125201, "𞤳"), + (125202, "𞤴"), + (125203, "𞤵"), + (125204, "𞤶"), + (125205, "𞤷"), + (125206, "𞤸"), + (125207, "𞤹"), + (125208, "𞤺"), + (125209, "𞤻"), + (125210, "𞤼"), + (125211, "𞤽"), + (125212, "𞤾"), + (125213, "𞤿"), + (125214, "𞥀"), + (125215, "𞥁"), + (125216, "𞥂"), + (125217, "𞥃") +] + +def folds : Array (Nat × String) := foldBlock0 ++ foldBlock1 ++ foldBlock2 ++ foldBlock3 ++ foldBlock4 ++ foldBlock5 ++ foldBlock6 ++ foldBlock7 ++ foldBlock8 ++ foldBlock9 ++ foldBlock10 ++ foldBlock11 ++ foldBlock12 ++ foldBlock13 ++ foldBlock14 ++ foldBlock15 ++ foldBlock16 ++ foldBlock17 ++ foldBlock18 ++ foldBlock19 ++ foldBlock20 ++ foldBlock21 ++ foldBlock22 ++ foldBlock23 ++ foldBlock24 + +def digitBlock0 : Array Nat := #[48,49,50,51,52,53,54,55,56,57,178,179,185,1632,1633,1634,1635,1636,1637,1638,1639,1640,1641,1776,1777,1778,1779,1780,1781,1782,1783,1784,1785,1984,1985,1986,1987,1988,1989,1990,1991,1992,1993,2406,2407,2408,2409,2410,2411,2412,2413,2414,2415,2534,2535,2536,2537,2538,2539,2540,2541,2542,2543,2662,2663,2664,2665,2666,2667,2668,2669,2670,2671,2790,2791,2792,2793,2794,2795,2796,2797,2798,2799,2918,2919,2920,2921,2922,2923,2924,2925,2926,2927,3046,3047,3048,3049,3050,3051,3052,3053,3054,3055,3174,3175,3176,3177,3178,3179,3180,3181,3182,3183,3302,3303,3304,3305,3306,3307,3308,3309,3310,3311,3430,3431,3432,3433,3434] +def digitBlock1 : Array Nat := #[3435,3436,3437,3438,3439,3558,3559,3560,3561,3562,3563,3564,3565,3566,3567,3664,3665,3666,3667,3668,3669,3670,3671,3672,3673,3792,3793,3794,3795,3796,3797,3798,3799,3800,3801,3872,3873,3874,3875,3876,3877,3878,3879,3880,3881,4160,4161,4162,4163,4164,4165,4166,4167,4168,4169,4240,4241,4242,4243,4244,4245,4246,4247,4248,4249,4969,4970,4971,4972,4973,4974,4975,4976,4977,6112,6113,6114,6115,6116,6117,6118,6119,6120,6121,6160,6161,6162,6163,6164,6165,6166,6167,6168,6169,6470,6471,6472,6473,6474,6475,6476,6477,6478,6479,6608,6609,6610,6611,6612,6613,6614,6615,6616,6617,6618,6784,6785,6786,6787,6788,6789,6790,6791,6792,6793,6800,6801,6802] +def digitBlock2 : Array Nat := #[6803,6804,6805,6806,6807,6808,6809,6992,6993,6994,6995,6996,6997,6998,6999,7000,7001,7088,7089,7090,7091,7092,7093,7094,7095,7096,7097,7232,7233,7234,7235,7236,7237,7238,7239,7240,7241,7248,7249,7250,7251,7252,7253,7254,7255,7256,7257,8304,8308,8309,8310,8311,8312,8313,8320,8321,8322,8323,8324,8325,8326,8327,8328,8329,9312,9313,9314,9315,9316,9317,9318,9319,9320,9332,9333,9334,9335,9336,9337,9338,9339,9340,9352,9353,9354,9355,9356,9357,9358,9359,9360,9450,9461,9462,9463,9464,9465,9466,9467,9468,9469,9471,10102,10103,10104,10105,10106,10107,10108,10109,10110,10112,10113,10114,10115,10116,10117,10118,10119,10120,10122,10123,10124,10125,10126,10127,10128,10129] +def digitBlock3 : Array Nat := #[10130,42528,42529,42530,42531,42532,42533,42534,42535,42536,42537,43216,43217,43218,43219,43220,43221,43222,43223,43224,43225,43264,43265,43266,43267,43268,43269,43270,43271,43272,43273,43472,43473,43474,43475,43476,43477,43478,43479,43480,43481,43504,43505,43506,43507,43508,43509,43510,43511,43512,43513,43600,43601,43602,43603,43604,43605,43606,43607,43608,43609,44016,44017,44018,44019,44020,44021,44022,44023,44024,44025,65296,65297,65298,65299,65300,65301,65302,65303,65304,65305,66720,66721,66722,66723,66724,66725,66726,66727,66728,66729,68160,68161,68162,68163,68912,68913,68914,68915,68916,68917,68918,68919,68920,68921,68928,68929,68930,68931,68932,68933,68934,68935,68936,68937,69216,69217,69218,69219,69220,69221,69222,69223,69224,69714,69715,69716,69717] +def digitBlock4 : Array Nat := #[69718,69719,69720,69721,69722,69734,69735,69736,69737,69738,69739,69740,69741,69742,69743,69872,69873,69874,69875,69876,69877,69878,69879,69880,69881,69942,69943,69944,69945,69946,69947,69948,69949,69950,69951,70096,70097,70098,70099,70100,70101,70102,70103,70104,70105,70384,70385,70386,70387,70388,70389,70390,70391,70392,70393,70736,70737,70738,70739,70740,70741,70742,70743,70744,70745,70864,70865,70866,70867,70868,70869,70870,70871,70872,70873,71248,71249,71250,71251,71252,71253,71254,71255,71256,71257,71360,71361,71362,71363,71364,71365,71366,71367,71368,71369,71376,71377,71378,71379,71380,71381,71382,71383,71384,71385,71386,71387,71388,71389,71390,71391,71392,71393,71394,71395,71472,71473,71474,71475,71476,71477,71478,71479,71480,71481,71904,71905,71906] +def digitBlock5 : Array Nat := #[71907,71908,71909,71910,71911,71912,71913,72016,72017,72018,72019,72020,72021,72022,72023,72024,72025,72688,72689,72690,72691,72692,72693,72694,72695,72696,72697,72784,72785,72786,72787,72788,72789,72790,72791,72792,72793,73040,73041,73042,73043,73044,73045,73046,73047,73048,73049,73120,73121,73122,73123,73124,73125,73126,73127,73128,73129,73552,73553,73554,73555,73556,73557,73558,73559,73560,73561,90416,90417,90418,90419,90420,90421,90422,90423,90424,90425,92768,92769,92770,92771,92772,92773,92774,92775,92776,92777,92864,92865,92866,92867,92868,92869,92870,92871,92872,92873,93008,93009,93010,93011,93012,93013,93014,93015,93016,93017,93552,93553,93554,93555,93556,93557,93558,93559,93560,93561,118000,118001,118002,118003,118004,118005,118006,118007,118008,118009,120782] +def digitBlock6 : Array Nat := #[120783,120784,120785,120786,120787,120788,120789,120790,120791,120792,120793,120794,120795,120796,120797,120798,120799,120800,120801,120802,120803,120804,120805,120806,120807,120808,120809,120810,120811,120812,120813,120814,120815,120816,120817,120818,120819,120820,120821,120822,120823,120824,120825,120826,120827,120828,120829,120830,120831,123200,123201,123202,123203,123204,123205,123206,123207,123208,123209,123632,123633,123634,123635,123636,123637,123638,123639,123640,123641,124144,124145,124146,124147,124148,124149,124150,124151,124152,124153,124401,124402,124403,124404,124405,124406,124407,124408,124409,124410,125264,125265,125266,125267,125268,125269,125270,125271,125272,125273,127232,127233,127234,127235,127236,127237,127238,127239,127240,127241,127242,130032,130033,130034,130035,130036,130037,130038,130039,130040,130041] + +def digits : Array Nat := digitBlock0 ++ digitBlock1 ++ digitBlock2 ++ digitBlock3 ++ digitBlock4 ++ digitBlock5 ++ digitBlock6 + +def foldMap : Std.HashMap Nat String := Std.HashMap.ofList folds.toList + +def fold (c : Char) : String := (foldMap[c.toNat]?).getD (String.singleton c) + +def isDigit (c : Char) : Bool := digits.contains c.toNat + +end Eggshell.SearchUnicode diff --git a/Eggshell/Setup.lean b/Eggshell/Setup.lean new file mode 100644 index 0000000..4f7c0a5 --- /dev/null +++ b/Eggshell/Setup.lean @@ -0,0 +1,56 @@ +module + +public import Eggshell.Install + +@[expose] public section + +namespace Eggshell.Setup +open Lean + +inductive Action where + | inspect | initialize + deriving BEq, DecidableEq + +def action (configured checkOnly : Bool) : Action := + if configured || checkOnly then .inspect else .initialize + +theorem configured_is_preserved (checkOnly : Bool) : action true checkOnly = .inspect := rfl +theorem check_never_initializes (configured : Bool) : action configured true = .inspect := by + simp [action] +theorem initialize_only_when_missing (configured checkOnly : Bool) + (h : action configured checkOnly = .initialize) : configured = false ∧ checkOnly = false := by + cases configured <;> cases checkOnly <;> simp_all [action] + +def report (project : System.FilePath) : IO Json := do + let out ← IO.Process.output { + cmd := (← IO.appPath).toString + args := #["egg", "doctor"] + cwd := some project + env := #[("CODEX_THREAD_ID", none)] } + if out.exitCode != 0 then throw (IO.userError out.stderr) + IO.ofExcept (Json.parse out.stdout) + +def command (args : List String) : IO UInt32 := do + let rec parse (project : System.FilePath) (checkOnly : Bool) : List String → Except String _ + | [] => .ok (project, checkOnly) + | "--project" :: value :: rest => parse (.mk value) checkOnly rest + | "--check" :: rest => parse project true rest + | _ => .error "usage: eggshell setup [--project PATH] [--check]" + let (project, checkOnly) ← IO.ofExcept (parse (← IO.currentDir) false args) + let project ← IO.FS.realPath project + if !(← project.isDir) then throw (IO.userError "project must be a directory") + let existing ← report project + match action (existing.getObjValD "configuration" == .str "ready") checkOnly with + | .inspect => pure () + | .initialize => + let _ ← IO.Process.run { + cmd := (← IO.appPath).toString + args := #["egg", "init"] + cwd := some project + env := #[("CODEX_THREAD_ID", none)] } + pure () + let final ← report project + IO.println final.pretty + pure (if final.getObjValD "configuration" == .str "ready" then 0 else 1) + +end Eggshell.Setup diff --git a/Eggshell/Sha256.lean b/Eggshell/Sha256.lean new file mode 100644 index 0000000..baa5156 --- /dev/null +++ b/Eggshell/Sha256.lean @@ -0,0 +1,65 @@ +module + +public import Eggshell.Blake3 + +@[expose] public section + +namespace Eggshell.Sha256 + +def constants : Array UInt32 := #[ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2] + +def rotate (x : UInt32) (n : UInt32) : UInt32 := (x >>> n) ||| (x <<< (32-n)) + +def compress (state : Array UInt32) (bytes : ByteArray) (offset : Nat) : Array UInt32 := Id.run do + let mut words := #[] + for i in [0:16] do + let p := offset + i*4 + words := words.push ((bytes[p]!.toUInt32 <<< 24) ||| (bytes[p+1]!.toUInt32 <<< 16) ||| + (bytes[p+2]!.toUInt32 <<< 8) ||| bytes[p+3]!.toUInt32) + for i in [16:64] do + let x := words[i-15]! + let y := words[i-2]! + words := words.push (words[i-16]! + + (rotate x 7 ^^^ rotate x 18 ^^^ (x >>> 3)) + words[i-7]! + + (rotate y 17 ^^^ rotate y 19 ^^^ (y >>> 10))) + let mut a := state[0]! + let mut b := state[1]! + let mut c := state[2]! + let mut d := state[3]! + let mut e := state[4]! + let mut f := state[5]! + let mut g := state[6]! + let mut h := state[7]! + for i in [0:64] do + let t1 := h + (rotate e 6 ^^^ rotate e 11 ^^^ rotate e 25) + + ((e &&& f) ^^^ (~~~e &&& g)) + constants[i]! + words[i]! + let t2 := (rotate a 2 ^^^ rotate a 13 ^^^ rotate a 22) + + ((a &&& b) ^^^ (a &&& c) ^^^ (b &&& c)) + h := g; g := f; f := e; e := d + t1 + d := c; c := b; b := a; a := t1 + t2 + return (state.zip #[a,b,c,d,e,f,g,h]).map fun (x,y) => x+y + +def digest (input : ByteArray) : ByteArray := Id.run do + let mut bytes := input.push 0x80 + let padding := (64 - ((bytes.size + 8) % 64)) % 64 + for _ in [0:padding] do bytes := bytes.push 0 + let bits := input.size.toUInt64 * 8 + for i in [0:8] do bytes := bytes.push ((bits >>> ((7-i)*8).toUInt64).toUInt8) + let mut state := Blake3.iv + for block in [0:bytes.size/64] do state := compress state bytes (block*64) + let mut result := ByteArray.empty + for word in state do + for i in [0:4] do result := result.push ((word >>> ((3-i)*8).toUInt32).toUInt8) + return result + +def hex (bytes : ByteArray) : String := Blake3.hex (digest bytes) + +end Eggshell.Sha256 diff --git a/Main.lean b/Main.lean index b728861..ebfcd53 100644 --- a/Main.lean +++ b/Main.lean @@ -1,14 +1,18 @@ module public import Eggshell.Install +public import Eggshell.SearchProvider +public import Eggshell.Setup @[expose] public section def usage : String := - "usage: eggshell install codex\n eggshell install runtime\n eggshell uninstall codex\n egg init\n egg [COMMAND]" + "usage: eggshell install codex\n eggshell install runtime\n eggshell setup [--project PATH] [--check]\n eggshell uninstall codex\n egg init\n egg [COMMAND]" def main (arguments : List String) : IO UInt32 := do match arguments with + | "search-provider" :: options => Eggshell.SearchProvider.run options + | "setup" :: options => Eggshell.Setup.command options | ["codex-hook"] => Eggshell.Plugin.Daemon.hookClient | ["codex-daemon", "shutdown"] => do Eggshell.Plugin.Daemon.shutdown diff --git a/PRIVACY.md b/PRIVACY.md index f407957..a0d9ea7 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -50,8 +50,8 @@ command, to disable both recording and handoff delivery for that session. `$EGGSHELL_PREFIX/share/eggshell/plugin`. Integrations use this stable root, with separate adapter session namespaces for each harness. It does not select or relocate any saved `.egg` file. -- Adapters store opaque turn/call identifiers and correlation hashes in - per-session SQLite files under the data root's `adapters` directory. Writable +- Adapters store session ownership, opaque turn/call identifiers, and correlation + hashes in per-session JSON files under the data root's `adapters` directory. Writable turns may also retain an authorized turn snapshot for late tool results and a temporary answer candidate under their session directory. Ambiguous Gemini tool results, when recording is permitted, stay in `adapters/unattributed` diff --git a/README.md b/README.md index 63a95d0..026f9d0 100644 --- a/README.md +++ b/README.md @@ -33,24 +33,23 @@ It saves work from one chat and makes relevant results available to a separate chat: repository searches, commands, documentation findings, and conclusions. It is useful when you return to related work in the same project. -Use the published **Codex plugin**, or the separate, experimental -[adapters for Claude Code, Gemini CLI, Cursor, and OpenCode](adapters/README.md). -The adapters use the same memory engine and are built and installed separately. -They have automated engine integration tests; live agent sessions and token -savings have not yet been evaluated for those four clients. - -In the [recorded LLVM walkthrough](docs/demo.md), one chat maps how Clang chooses -a toolchain. A new chat reuses those findings to investigate language and target -edge cases, and reports what remains unverified. You ask ordinary questions; -Eggshell selects prior work automatically. The walkthrough is an edited English -summary of the study, with links to its measurement record. - In our LLVM follow-up experiment, Eggshell used **about 80% fewer tokens than starting fresh**, with **9 of 10 answers needing no substantive correction**. Memory is built and organized locally, **without LLM calls or additional billed tokens for memory management**. These results cover one task with existing prior work; see [Evidence](#evidence) for the comparison and its limits. +**[Watch the 30-second walkthrough](docs/demo.md)** or +**[try it in two chats](docs/try-it.md)**. Ask ordinary questions: Eggshell +automatically brings relevant findings from the first investigation into the +follow-up. The walkthrough is an edited English summary of the measured study. + +Use the published **Codex plugin**, or the separate, experimental +[adapters for Claude Code, Gemini CLI, Cursor, and OpenCode](adapters/README.md). +The adapters use the same memory engine and are built and installed separately. +They have automated engine integration tests; live agent sessions and token +savings have not yet been evaluated for those four clients. + ## Install For **Claude Code, Gemini CLI, Cursor, or OpenCode**, follow the @@ -148,6 +147,10 @@ Eggshell preserves the earlier outcome so the agent can explain what changed. Search and graph processing run locally. Eggshell does not ask an LLM to write summaries, classify memories, or maintain the graph. Selected memory and the agent's subsequent work still consume the model's normal input and output tokens. +The engine, adapters, retrieval selection, setup logic, and package builder are +written in Lean. Python is confined to FastEmbed inference and the existing +NumPy numerical kernels; it does not organize memory or select handoffs. +See [verified contracts and runtime boundaries](docs/lean-boundaries.md). See the [architecture reference](docs/architecture.md) for matching, graph operations, and the Lean core. diff --git a/TestMain.lean b/TestMain.lean index db2c80a..7c450a4 100644 --- a/TestMain.lean +++ b/TestMain.lean @@ -1,6 +1,8 @@ module public import Eggshell.Install +public import Eggshell.SearchProvider +import Eggshell.ContractAudit @[expose] public section @@ -942,11 +944,10 @@ def testMiniLMDefault : IO Unit := do let python := MiniLM.unixPython paths if let some parent := python.parent then IO.FS.createDirAll parent IO.FS.writeFile python "" - IO.FS.writeFile paths.provider MiniLM.providerSource let some command ← MiniLM.command home pluginData | throw (IO.userError "installed MiniLM runtime was not selected by default") - check (command.head? = some python.toString && - command.contains paths.provider.toString && + check (command.head? = some (← IO.appPath).toString && + command.contains "search-provider" && command.contains paths.vectors.toString && command.contains MiniLM.model) "default MiniLM command escaped its private runtime or Plugin cache" @@ -1732,6 +1733,7 @@ def runTests : IO UInt32 := do def main (arguments : List String) : IO UInt32 := match arguments with + | "search-provider" :: args => SearchProvider.run args | ["codex-worker", role] => Worker.run role | ["codex-daemon", session] => Plugin.Daemon.run session | ["codex-rpc", kind] => Plugin.Daemon.rpcClient kind diff --git a/adapters/README.md b/adapters/README.md index 2d0d036..169a213 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -22,9 +22,10 @@ deliver it. This difference is part of the integration contract. ## Install -You need macOS or Linux, Python 3.9 or later, the project's pinned Lean -toolchain, and an agent version supporting the events listed below. OpenCode -uses its own JavaScript runtime; the plugin has no npm dependencies. +You need macOS or Linux, the project's pinned Lean toolchain, and an agent +version supporting the events listed below. Adapters run as a native Lean +executable. Python is used only by the FastEmbed/NumPy numerical backend. +OpenCode uses its own JavaScript runtime for host callbacks. From an Eggshell source checkout, install the local runtime and search model, then build the separate adapter companion: @@ -33,7 +34,7 @@ then build the separate adapter companion: lake build eggshell .lake/build/bin/eggshell install runtime export PATH="${EGGSHELL_PREFIX:-$HOME/.local}/bin:$PATH" -(cd adapters/native && lake build) +(cd adapters/native && lake build eggshell_bridge) ``` In a project without existing Eggshell project, parent, or global configuration, @@ -43,10 +44,10 @@ profiles continue to apply. Back in the source checkout, choose **one** adapter: ```sh -python3 adapters/install.py claude --project /absolute/path/to/project -python3 adapters/install.py gemini --project /absolute/path/to/project -python3 adapters/install.py cursor --project /absolute/path/to/project -python3 adapters/install.py opencode --project /absolute/path/to/project +adapters/native/.lake/build/bin/eggshell_bridge install claude --project /absolute/path/to/project +adapters/native/.lake/build/bin/eggshell_bridge install gemini --project /absolute/path/to/project +adapters/native/.lake/build/bin/eggshell_bridge install cursor --project /absolute/path/to/project +adapters/native/.lake/build/bin/eggshell_bridge install opencode --project /absolute/path/to/project ``` The installer copies the companion and adapters into @@ -58,6 +59,9 @@ The installer does not enable globally disabled hooks or approve project trust. Review the new hooks or plugin in your agent, then restart the project chat. Installation alone does not demonstrate that memory is being saved or delivered. +When upgrading from the experimental Python adapter, start a new chat: active +SQLite correlation state is not imported. The native adapter rejects that old +chat state, and existing `.egg` memory remains available to new chats. | Agent | Project file | | --- | --- | @@ -79,9 +83,9 @@ The first chat's tool results should reach `.eggs/work.egg` before it finishes. For inspection, supply the native session ID from the agent's hook/debug output: ```sh -python3 "$HOME/.local/share/eggshell-adapters/eggshell_adapter.py" \ +"$HOME/.local/share/eggshell-adapters/eggshell-bridge" \ control claude --session NATIVE_SESSION_ID doctor -python3 "$HOME/.local/share/eggshell-adapters/eggshell_adapter.py" \ +"$HOME/.local/share/eggshell-adapters/eggshell-bridge" \ control claude --session NATIVE_SESSION_ID graph ``` @@ -96,7 +100,7 @@ It launches no model turn. From the source checkout: ```sh -python3 adapters/install.py claude --project /absolute/path/to/project --uninstall +adapters/native/.lake/build/bin/eggshell_bridge install claude --project /absolute/path/to/project --uninstall ``` Only the exact entries installed by this adapter are removed. Modified or @@ -106,9 +110,10 @@ memory are preserved. ## Boundaries and guarantees - **One engine manager per chat.** The adapter namespaces native session IDs by - harness. Its small SQLite database records opaque turn/call identifiers; - transactions finish before any engine or search call. It starts no additional - adapter manager and holds no cross-chat lock during normal operation. + harness and checks the full stored identity before reading correlation state. + Atomic JSON records replace the former Python/SQLite state. A short per-chat + transaction journals terminal results before marking calls consumed; no RPC + or search runs under that lock. It starts no additional adapter manager. - **The engine owns memory.** Tool names, inputs, and results are passed through to the existing engine. Its journal captures results before manager RPC or search. Search and saving retain their existing independent workers. @@ -149,22 +154,24 @@ without inventing an answer. ## Development and contract tests ```sh -(cd adapters/native && lake build) -python3 tests/test_adapters.py -v +(cd adapters/native && lake build eggshell_bridge adapter_tests && .lake/build/bin/adapter_tests) node --test tests/test_opencode_adapter.mjs ``` -Tests exercise actual engine processes, saving before turn completion, reuse in +Tests are written in Lean and exercise actual engine processes, saving before turn completion, reuse in a separate chat, concurrent tool results, off mode, compaction receipts, output translation, and installation ownership. They make no LLM or network calls. The OpenCode output-object tests separately verify insertion-before-ack order. The dependency is one-way: `adapters/native` imports the engine as a local Lake -dependency. `adapters/eggshell_adapter.py` owns host JSON translation and -identifier correlation; `adapters/opencode.mjs` owns OpenCode plugin callbacks. +dependency. Its `Adapter/Protocol.lean` owns host JSON translation and identifier +correlation; `adapters/opencode.mjs` owns OpenCode plugin callbacks. The companion translates neither host tools nor retrieval results. No adapter code is linked into the existing `eggshell` executable or Codex plugin. +The executable calls the functions proved in `Adapter/Contracts.lean`. +See [Lean contracts and trusted boundaries](../docs/lean-boundaries.md). + Reference contracts checked on 2026-09-12: [Claude Code hooks](https://code.claude.com/docs/en/hooks), [Gemini CLI hooks](https://geminicli.com/docs/hooks/reference/), diff --git a/adapters/eggshell_adapter.py b/adapters/eggshell_adapter.py deleted file mode 100644 index f06d6a1..0000000 --- a/adapters/eggshell_adapter.py +++ /dev/null @@ -1,395 +0,0 @@ -#!/usr/bin/env python3 -"""Host adapters for Eggshell's separate bridge; no retrieval implementation. - -Only opaque turn/call identifiers live in the adapter database. Tool bodies are -journaled by the engine before RPC/search. All locks end before a process call. -""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import signal -import sqlite3 -import subprocess -import sys -import uuid - -CLIENTS = ('claude', 'gemini', 'cursor', 'opencode') -EVENTS = { - 'claude': dict(SessionStart='SessionStart', UserPromptSubmit='UserPromptSubmit', - PreToolUse='PreToolUse', PostToolUse='PostToolUse', - PostToolUseFailure='PostToolUse', Stop='Stop', - StopFailure='Interrupt', SessionEnd='SessionEnd'), - 'gemini': dict(SessionStart='SessionStart', BeforeAgent='UserPromptSubmit', - BeforeTool='PreToolUse', AfterTool='PostToolUse', - AfterAgent='Stop', PreCompress='PostCompact', SessionEnd='SessionEnd'), - 'cursor': dict(sessionStart='SessionStart', beforeSubmitPrompt='UserPromptSubmit', - preToolUse='PreToolUse', postToolUse='PostToolUse', - postToolUseFailure='PostToolUse', afterAgentResponse='AssistantMessage', - stop='Stop', preCompact='PostCompact', sessionEnd='SessionEnd'), - 'opencode': {name: name for name in ('SessionStart', 'UserPromptSubmit', - 'PreToolUse', 'PostToolUse', 'PostCompact', 'Stop', 'Interrupt', 'SessionEnd')}, -} -CONTEXT_EVENTS = { - 'claude': ('UserPromptSubmit', 'PreToolUse', 'PostToolUse'), - 'gemini': ('UserPromptSubmit', 'PostToolUse'), - 'cursor': ('PostToolUse',), - 'opencode': ('UserPromptSubmit', 'PostToolUse'), -} - - -def encode(value): - return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':')) - - -def session_key(client, native): - if client not in CLIENTS or not isinstance(native, str) or not native: - raise ValueError('hook must provide a nonempty native session ID') - return client + '-' + hashlib.sha256(native.encode()).hexdigest() - - -def data_root(): - prefix = Path(os.environ.get('EGGSHELL_PREFIX', str(Path.home() / '.local'))) - root = Path(os.environ.get('EGGSHELL_DATA_ROOT', str(prefix / 'share/eggshell/plugin'))) - if not root.is_absolute(): - raise ValueError('EGGSHELL_DATA_ROOT must be absolute') - return root / 'adapters' - - -def require_text(raw, key): - value = raw.get(key) - if not isinstance(value, str) or not value: - raise ValueError('hook must provide ' + key) - return value - - -class UnattributedResult(ValueError): - def __init__(self, event, recordable=False): - super().__init__('tool result has no unambiguous originating turn; not attached to another task') - self.event = dict(event) - self.event.pop('turn_id', None) - self.recordable = recordable - - -class Correlation: - """A short per-chat transaction, never a search lock or a second manager.""" - def __init__(self, root, session): - root.mkdir(parents=True, exist_ok=True, mode=0o700) - root.chmod(0o700) - path = root / (session + '.sqlite3') - self.db = sqlite3.connect(path, timeout=.1) - path.chmod(0o600) - self.db.execute('PRAGMA secure_delete=ON') - self.db.executescript(''' - CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS calls ( - id TEXT PRIMARY KEY, native TEXT NOT NULL, turn TEXT NOT NULL, - finished INTEGER NOT NULL DEFAULT 0, writable INTEGER NOT NULL DEFAULT 0); - CREATE INDEX IF NOT EXISTS calls_native ON calls(native, finished); - CREATE TABLE IF NOT EXISTS receipts (stamp TEXT PRIMARY KEY, id TEXT NOT NULL, turn TEXT NOT NULL); - ''') - - def __enter__(self): - self.db.execute('BEGIN IMMEDIATE') - return self - - def __exit__(self, error_type, error, traceback): - if error_type: - self.db.rollback() - else: - self.db.commit() - self.db.close() - - def get(self, key): - row = self.db.execute('SELECT value FROM state WHERE key=?', (key,)).fetchone() - return row[0] if row else None - - def put(self, key, value): - self.db.execute('INSERT OR REPLACE INTO state VALUES (?,?)', (key, value)) - - def bind(self, native, turn, explicit): - call = explicit or uuid.uuid4().hex - existing = self.db.execute('SELECT turn FROM calls WHERE id=?', (call,)).fetchone() - if existing and existing[0] != turn: - raise ValueError('native tool ID was reused across different turns') - self.db.execute('INSERT OR IGNORE INTO calls(id,native,turn) VALUES (?,?,?)', - (call, native, turn)) - return call - - def terminal(self, native, explicit, turn): - if explicit: - rows = self.db.execute('SELECT id,turn FROM calls WHERE id=?', (explicit,)).fetchall() - else: - rows = self.db.execute('SELECT id,turn FROM calls WHERE native=? AND finished=0 ' - 'ORDER BY rowid', (native,)).fetchall() - if turn: - rows = [row for row in rows if row[1] == turn] - if not rows and explicit and turn: - return self.bind(native, turn, explicit), turn - if not rows or len({row[1] for row in rows}) != 1: - raise ValueError('tool result has no unambiguous originating turn') - call, original_turn = rows[0] - self.db.execute('UPDATE calls SET finished=1 WHERE id=?', (call,)) - return call, original_turn - - -def normalize(client, raw, root): - if not isinstance(raw, dict): - raise ValueError('hook input must be an object') - native_event = raw.get('hook_event_name') - event = EVENTS[client].get(native_event) - if event is None: - return None - native = require_text(raw, 'conversation_id' if client == 'cursor' else 'session_id') - session = session_key(client, native) - cwd = raw.get('cwd') - if not cwd: - roots = raw.get('workspace_roots', []) - if not isinstance(roots, list) or len(roots) != 1: - raise ValueError('hook must identify one project working directory') - cwd = roots[0] - if not isinstance(cwd, str) or not Path(cwd).is_absolute(): - raise ValueError('hook working directory must be absolute') - result = dict(session_id=session, cwd=cwd, hook_event_name=event) - # Do not forward account details, transcripts, reasoning, or internal fields. - for key in ('source', 'prompt', 'tool_name', 'tool_input', 'tool_response'): - if key in raw: - result[key] = raw[key] - explicit_turn = raw.get('generation_id' if client == 'cursor' else 'turn_id') - if explicit_turn is not None and (not isinstance(explicit_turn, str) or not explicit_turn): - raise ValueError('invalid native turn ID') - with Correlation(root, session) as state: - if event == 'UserPromptSubmit': - require_text(raw, 'prompt') - turn = explicit_turn or uuid.uuid4().hex - state.put('turn', turn) - result['turn_id'] = turn - return result - turn = explicit_turn or state.get('turn') - if turn: - result['turn_id'] = turn - if event in ('PreToolUse', 'PostToolUse'): - name = require_text(raw, 'tool_name') - if 'tool_input' not in raw: - raise ValueError('missing tool_input') - call = raw.get('tool_use_id') - if call is not None and (not isinstance(call, str) or not call): - raise ValueError('invalid tool call ID') - native_call = call or hashlib.sha256(encode([name, raw['tool_input']]).encode()).hexdigest() - stamp = raw.get('timestamp') - stamp_key = hashlib.sha256(encode([native_event, native_call, stamp]).encode()).hexdigest() \ - if isinstance(stamp, str) and stamp else None - if event == 'PreToolUse': - if not turn: - raise ValueError('tool arrived before a user turn') - result['tool_use_id'] = state.bind(native_call, turn, call or stamp_key) - else: - if native_event in ('PostToolUseFailure', 'postToolUseFailure'): - result['tool_response'] = dict(error=raw.get('error'), is_error=True) - elif client == 'cursor': - response = raw.get('tool_output') - if isinstance(response, str): - try: - response = json.loads(response) - except ValueError: - pass - result['tool_response'] = response - elif 'tool_response' not in raw: - raise ValueError('missing tool_response') - previous = state.db.execute('SELECT id,turn FROM receipts WHERE stamp=?', - (stamp_key,)).fetchone() if stamp_key else None - try: - call, turn = previous or state.terminal(native_call, call, explicit_turn) - except ValueError: - permissions = state.db.execute('SELECT writable FROM calls WHERE native=?', - (native_call,)).fetchall() - raise UnattributedResult(result, bool(permissions) and - all(row[0] for row in permissions)) from None - result.update(tool_use_id=call, turn_id=turn) - if stamp_key: - state.db.execute('INSERT OR IGNORE INTO receipts VALUES (?,?,?)', (stamp_key, call, turn)) - if event == 'Stop': - field = 'prompt_response' if client == 'gemini' else 'last_assistant_message' - final = raw.get(field) - if isinstance(final, str): - result['last_assistant_message'] = final - return result - - -def translate(client, event, output): - """Return only fields accepted by the host for this event. - - End-of-turn hooks always return {}, regardless of the engine reply. - Unsupported advisory output is never acknowledged as delivered. - """ - if event not in ('UserPromptSubmit', 'PreToolUse', 'PostToolUse'): - return {} - specific = output.get('hookSpecificOutput', {}) - if not isinstance(specific, dict): - return {} - if event == 'PreToolUse' and specific.get('permissionDecision') == 'deny': - reason = specific.get('permissionDecisionReason', '') - if not isinstance(reason, str) or not reason: - return {} - if client in ('claude', 'opencode'): - return {'hookSpecificOutput': dict(hookEventName='PreToolUse', - permissionDecision='deny', permissionDecisionReason=reason)} - if client == 'gemini': - return dict(decision='deny', reason=reason) - return dict(permission='deny', agent_message=reason) - context = specific.get('additionalContext', '') - if event not in CONTEXT_EVENTS[client] or not isinstance(context, str) or not context: - return {} - if client == 'cursor': - return dict(additional_context=context) - target_event = ('BeforeAgent' if event == 'UserPromptSubmit' else 'AfterTool') \ - if client == 'gemini' else event - return {'hookSpecificOutput': dict(hookEventName=target_event, additionalContext=context)} - - -def invoke(bridge, command, payload, cwd, session=None): - env = dict(os.environ) - # An installed Codex plugin must not redirect this companion's manager. - env.pop('PLUGIN_ROOT', None) - env.pop('CODEX_THREAD_ID', None) - if session: - env['CODEX_THREAD_ID'] = session - child = subprocess.Popen([str(bridge), *command], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env, - start_new_session=True, text=True) - try: - stdout, stderr = child.communicate(encode(payload) if payload is not None else '', timeout=30) - except subprocess.TimeoutExpired: - os.killpg(child.pid, signal.SIGKILL) - child.communicate() - raise RuntimeError('adapter bridge timed out; the engine retains captured tool results') - if stderr: - print(stderr.rstrip(), file=sys.stderr) - if child.returncode: - raise RuntimeError('adapter bridge exited with code ' + str(child.returncode)) - return json.loads(stdout) if stdout.strip() else {} - - -def is_denied(reply): - return (reply.get('permission') == 'deny' or reply.get('decision') == 'deny' or - reply.get('hookSpecificOutput', {}).get('permissionDecision') == 'deny') - - -def record_call_status(root, event, reply, writable): - if event['hook_event_name'] != 'PreToolUse': - return - with Correlation(root, event['session_id']) as state: - state.db.execute('UPDATE calls SET finished=?, writable=? WHERE id=?', - (int(is_denied(reply)), int(writable), event['tool_use_id'])) - - -def draft_response(bridge, event, raw): - """Cursor reports answer text separately from loop completion. - - Keep a temporary candidate only while memory is writable. Never turn an - intermediate assistant message or an aborted loop into a final outcome. - """ - turn = event.get('turn_id') - if not turn: - return - if event['hook_event_name'] == 'AssistantMessage': - if isinstance(raw.get('text'), str): - invoke(bridge, ['draft'], dict(event, text=raw['text']), event['cwd']) - elif event['hook_event_name'] == 'Stop': - event['_adapter_use_draft'] = raw.get('status') == 'completed' - - -def run_hook(client, raw, bridge, root): - event = normalize(client, raw, root) - if event is None: - print('{}', flush=True) - return - if client == 'cursor': - draft_response(bridge, event, raw) - if event['hook_event_name'] == 'AssistantMessage': - print('{}', flush=True) - return - receipt = invoke(bridge, ['deliver'], event, event['cwd']) - if not receipt.get('ok'): - print('{}', flush=True) - return - reply = translate(client, event['hook_event_name'], receipt['output']) - record_call_status(root, event, reply, receipt.get('writable', False)) - if client == 'opencode': - # The JS plugin confirms only after mutating the host's output object. - print(encode(dict(output=reply, receipt=receipt['receipt'], - session_id=event['session_id'])), flush=True) - else: - print(encode(reply), flush=True) - if reply: - try: - invoke(bridge, ['ack'], dict(session_id=event['session_id'], - receipt=receipt['receipt']), event['cwd']) - except (OSError, ValueError, RuntimeError) as error: - # stdout already contains the complete hook response. - print('Eggshell delivery receipt: ' + str(error), file=sys.stderr) - - -def retain_unattributed(bridge, root, event): - # Keep ambiguous evidence private, only when this chat permits writing. - report = invoke(bridge, ['egg', 'doctor'], None, event['cwd'], event['session_id']) - if report.get('memory') != 'read/write': - return - directory = root / 'unattributed' / event['session_id'] - directory.mkdir(parents=True, exist_ok=True, mode=0o700) - path = directory / (uuid.uuid4().hex + '.json') - with path.open('x') as stream: - os.chmod(path, 0o600) - stream.write(encode(event)) - stream.flush() - os.fsync(stream.fileno()) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('mode', choices=('hook', 'ack', 'control')) - parser.add_argument('client', choices=CLIENTS) - parser.add_argument('--bridge', type=Path, - default=Path(__file__).resolve().with_name('eggshell-bridge')) - parser.add_argument('--session') - parser.add_argument('commands', nargs='*') - args = parser.parse_intermixed_args() - if args.mode == 'control': - if not args.session: - parser.error('control requires --session with the native chat ID') - session = session_key(args.client, args.session) - env = dict(os.environ, CODEX_THREAD_ID=session) - env.pop('PLUGIN_ROOT', None) - if args.commands == ['doctor']: - report = invoke(args.bridge, ['egg', 'doctor'], None, os.getcwd(), session) - report['hook_trust'] = 'Review hooks in ' + args.client - report['next_step'] = 'Restart the agent and verify saving and delivery with the two-chat example.' - print(json.dumps(report, indent=2)) - return 0 - return subprocess.call([str(args.bridge), 'egg', *args.commands], env=env) - try: - root = data_root() - raw = json.load(sys.stdin) - if args.mode == 'ack': - if not require_text(raw, 'session_id').startswith(args.client + '-'): - raise ValueError('receipt belongs to another harness') - invoke(args.bridge, ['ack'], raw, os.getcwd()) - else: - run_hook(args.client, raw, args.bridge, root) - return 0 - except (OSError, ValueError, RuntimeError, sqlite3.Error) as error: - if isinstance(error, UnattributedResult) and error.recordable: - try: - retain_unattributed(args.bridge, root, error.event) - except (OSError, ValueError, RuntimeError) as retention_error: - print('Eggshell could not retain ambiguous evidence: ' + str(retention_error), file=sys.stderr) - print('Eggshell adapter: ' + str(error), file=sys.stderr) - if args.mode == 'hook': - print('{}', flush=True) - return 0 - return 1 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/adapters/install.py b/adapters/install.py deleted file mode 100644 index d78da38..0000000 --- a/adapters/install.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -"""Install one project adapter without changing the Eggshell or Codex package.""" -import argparse -import fcntl -import hashlib -import json -import os -from pathlib import Path -import shlex -import subprocess -import tempfile - -from eggshell_adapter import CLIENTS, EVENTS - -SOURCE = Path(__file__).resolve().parent -OWNER = 'momonpya/eggshell-adapters-v1\n' -CONFIGS = dict(claude='.claude/settings.json', gemini='.gemini/settings.json', - cursor='.cursor/hooks.json', opencode='.opencode/plugins/eggshell.js') - - -def atomic_write(path, contents, mode=0o600): - path.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix=path.name + '.', dir=path.parent) - try: - with os.fdopen(fd, 'wb') as stream: - stream.write(contents) - stream.flush() - os.fsync(stream.fileno()) - os.chmod(temporary, mode) - os.replace(temporary, path) - finally: - if os.path.exists(temporary): - os.unlink(temporary) - - -def write_json(path, value): - mode = path.stat().st_mode & 0o777 if path.exists() else 0o600 - atomic_write(path, (json.dumps(value, indent=2, ensure_ascii=False) + '\n').encode(), mode) - - -def read_document(path): - if not path.exists(): - return {} - value = json.loads(path.read_text()) - if not isinstance(value, dict): - raise ValueError(str(path) + ' must contain a JSON object') - return value - - -def remove_entries(document, entries, client): - hooks = document.get('hooks', {}) - if not isinstance(hooks, dict): - raise ValueError('existing hooks must be an object') - for event, owned in entries.items(): - current = hooks.get(event, []) - if not isinstance(current, list): - raise ValueError('existing hook event must contain an array: ' + event) - kept = [entry for entry in current if entry != owned] - if kept: - hooks[event] = kept - else: - hooks.pop(event, None) - if hooks: - document['hooks'] = hooks - else: - document.pop('hooks', None) - return document - - -def entries_for(client, command): - entries = {} - for event in EVENTS[client]: - # One common upper bound surrounds the engine's own bounded delivery. - # Stop hooks do not request retries, and errors use the host's fail-open default. - handler = dict(type='command', command=command, timeout=35000 if client == 'gemini' else 35) - if client == 'gemini': - handler['name'] = 'eggshell-' + event - entries[event] = handler if client == 'cursor' else dict(hooks=[handler]) - return entries - - -def install(client, project, prefix, bridge, uninstall=False): - project, prefix, bridge = project.resolve(), prefix.resolve(), bridge.resolve() - if not project.is_dir(): - raise ValueError('project directory does not exist') - support = prefix / 'share/eggshell-adapters' - marker = support / '.owner' - if support.exists() and (not marker.exists() or marker.read_text() != OWNER): - raise ValueError('refusing to replace an unowned adapter directory') - config = project / CONFIGS[client] - key = hashlib.sha256((client + '\n' + str(config)).encode()).hexdigest() - receipt_path = support / 'receipts' / (key + '.json') - previous = read_document(receipt_path) - if client == 'opencode': - old = config.read_text() if config.exists() else None - if old is not None and old != previous.get('contents'): - raise ValueError('refusing to replace an unowned OpenCode plugin') - document = None - else: - document = read_document(config) - if client == 'cursor' and document.get('version', 1) != 1: - raise ValueError('unsupported Cursor hooks schema version') - remove_entries(document, previous.get('entries', {}), client) - if uninstall: - if not previous: - return dict(status='not-installed', client=client, config=str(config)) - if client == 'opencode': - config.unlink(missing_ok=True) - else: - write_json(config, document) - receipt_path.unlink() - return dict(status='removed', client=client, config=str(config), - memory='preserved', runtime='preserved') - checked = subprocess.run([bridge, '--help'], capture_output=True, text=True, check=True) - if 'Eggshell adapter bridge:' not in checked.stdout: - raise ValueError('not an Eggshell adapter bridge executable') - support.mkdir(parents=True, exist_ok=True, mode=0o700) - atomic_write(marker, OWNER.encode()) - installed_bridge = support / 'eggshell-bridge' - if bridge != installed_bridge: - atomic_write(installed_bridge, bridge.read_bytes(), 0o755) - for name in ('eggshell_adapter.py', 'opencode.mjs'): - atomic_write(support / name, (SOURCE / name).read_bytes(), 0o644) - command = shlex.join(['env', 'EGGSHELL_PREFIX=' + str(prefix), 'python3', - str(support / 'eggshell_adapter.py'), 'hook', client]) - if client == 'opencode': - contents = ('// Eggshell project adapter. Remove with adapters/install.py --uninstall.\n' - 'export { Eggshell } from ' + json.dumps((support / 'opencode.mjs').as_uri()) + ';\n') - receipt = dict(client=client, config=str(config), contents=contents) - # Store ownership before publishing the project entrypoint. - write_json(receipt_path, receipt) - atomic_write(config, contents.encode(), 0o644) - else: - entries = entries_for(client, command) - hooks = document.setdefault('hooks', {}) - if not isinstance(hooks, dict): - raise ValueError('existing hooks must be an object') - for event, entry in entries.items(): - current = hooks.setdefault(event, []) - if not isinstance(current, list): - raise ValueError('existing hook event must contain an array: ' + event) - current.append(entry) - if client == 'cursor': - document.setdefault('version', 1) - write_json(receipt_path, dict(client=client, config=str(config), entries=entries)) - write_json(config, document) - return dict(status='configured', client=client, config=str(config), - next_step='Review the hooks in your agent, restart the project chat, then verify two-chat reuse.') - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('client', choices=CLIENTS) - parser.add_argument('--project', type=Path, default=Path.cwd()) - parser.add_argument('--prefix', type=Path, default=Path(os.environ.get( - 'EGGSHELL_PREFIX', str(Path.home() / '.local')))) - parser.add_argument('--bridge', type=Path, - default=SOURCE / 'native/.lake/build/bin/eggshell_bridge') - parser.add_argument('--uninstall', action='store_true') - args = parser.parse_args() - # Installation owns a separate lock; hook execution never takes it. - args.prefix.mkdir(parents=True, exist_ok=True) - with (args.prefix / '.eggshell-adapter-install.lock').open('a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - result = install(args.client, args.project, args.prefix, args.bridge, args.uninstall) - print(json.dumps(result, indent=2)) - - -if __name__ == '__main__': - main() diff --git a/adapters/native/Adapter/Bridge.lean b/adapters/native/Adapter/Bridge.lean new file mode 100644 index 0000000..3650f5f --- /dev/null +++ b/adapters/native/Adapter/Bridge.lean @@ -0,0 +1,115 @@ +module + +public import Eggshell.Daemon +public import Adapter.Contracts + +@[expose] public section + +open Lean Eggshell Eggshell.Plugin + +namespace Eggshell.Adapter.Bridge + +def draftPath (files : SessionFiles) (turn : String) : System.FilePath := + files.directory / "adapter-drafts" / + (Blake3.hex (Blake3.digest "eggshell.turn".toUTF8 [turn.toUTF8]) ++ ".json") + +def turnPath (files : SessionFiles) (turn : String) : System.FilePath := + files.directory / "adapter-turns" / + (Blake3.hex (Blake3.digest "eggshell.turn".toUTF8 [turn.toUTF8]) ++ ".json") + +/-- Retain the engine's original write authorization for late terminal hooks. + This is an immutable engine value, not a second graph or search policy. -/ +def rememberTurn (input : Json) : IO Unit := do + if optionalString input "hook_event_name" != some "UserPromptSubmit" then return + let session ← IO.ofExcept (requiredString input "session_id") + let turn ← IO.ofExcept (requiredString input "turn_id") + withSession session fun files => do + let some state ← readState? files | return + let some pending ← readPendingBase? files | return + if !state.enabled || pending.turnId != turn || pending.write.isNone then return + writeJson (turnPath files turn) { pending with tools := [], inFlight := [] } + +/-- A tool started under an earlier turn keeps that turn's write target even + when its terminal hook arrives after the engine has sealed/deferred it. -/ +def captureLateResult (input : Json) : IO Unit := do + if optionalString input "hook_event_name" != some "PostToolUse" then return + let session ← IO.ofExcept (requiredString input "session_id") + let turn ← IO.ofExcept (requiredString input "turn_id") + withSession session fun files => do + let some state ← readState? files | return + if !state.enabled then return + let current ← readPendingBase? files + if current.any (fun pending => pending.turnId == turn && !pending.closed && pending.finalMessage.isNone) then return + let some original ← (readJson? (turnPath files turn) : IO (Option PendingTurn)) | return + let tool ← IO.ofExcept (toolFromHook input true) + queueCheckpoint files { + original with tools := [tool], inFlight := [], finalMessage := none, closed := false } + +/-- Some hosts report answer text before loop completion. A candidate is local + adapter state, never a final engine outcome. Respect the selected turn's + write profile, including a one-turn read-only override. -/ +def draftAnswer (input : Json) : IO Unit := do + let session ← IO.ofExcept (requiredString input "session_id") + let turn ← IO.ofExcept (requiredString input "turn_id") + withSession session fun files => do + let some state ← readState? files | return + let some pending ← readPendingBase? files | return + if !maySaveAnswer state.enabled pending.write.isSome + (pending.turnId == turn && !pending.closed) true then return + let text ← IO.ofExcept (requiredString input "text") + writeJson (draftPath files turn) text + +def attachDraft (input : Json) : IO Json := do + if optionalString input "hook_event_name" != some "Stop" then return input + let session ← IO.ofExcept (requiredString input "session_id") + let some turn := optionalString input "turn_id" | return input + withSession session fun files => do + let path := draftPath files turn + let state ← readState? files + let pending ← readPendingBase? files + let permitted := maySaveAnswer (state.any (·.enabled)) + (pending.any (·.write.isSome)) + (pending.any fun p => p.turnId == turn && !p.closed) + (input.getObjValD "_adapter_use_draft" == true) + let text : Option String ← if permitted then + readJson? path + else pure none + removeIfExists path + pure (text.map (fun text => input.setObjVal! "last_assistant_message" (toJson text)) |>.getD input) + +/-- A separate companion executable. The memory engine and its Codex entrypoint + are imported unchanged; host-specific schemas live outside this package. -/ +def deliver (raw : Json) : IO Json := do + let input ← attachDraft (Daemon.attachClientConfig raw (← IO.getEnv "EGGSHELL_CONFIG")) + -- Use the engine's durable capture before any manager or search operation. + captureTerminal input + captureLateResult input + let event := (optionalString input "hook_event_name").getD "" + let fast := ["Stop", "Interrupt", "PostCompact", "SessionEnd"].contains event || + (event == "SessionStart" && optionalString input "source" == some "compact") + let deadline := (← IO.monoMsNow) + (if fast then 2000 else 26000) + let receipt := Blake3.hex (← IO.getRandomBytes 16) + let input := input.setObjVal! "_eggshell_deadline" (toJson (deadline - 250)) + |>.setObjVal! "_eggshell_receipt" (toJson receipt) + let result ← Daemon.boundedRpc "hook" input (deadline - 150) + rememberTurn input + let some result := result | + throw (IO.userError "memory hook did not return before its delivery deadline; captured results remain queued") + let reply ← IO.ofExcept (Json.parse result) + if let some error := optionalString reply "error" then throw (IO.userError error) + let output ← IO.ofExcept (Json.parse ((optionalString reply "output").getD "{}")) + let session ← IO.ofExcept (requiredString input "session_id") + let writable ← withSession session fun files => do + let state ← readState? files + let pending ← readPendingBase? files + pure <| state.any (·.enabled) && pending.any fun pending => + optionalString input "turn_id" == some pending.turnId && pending.write.isSome + pure (Json.mkObj [("ok", toJson true), ("output", output), + ("writable", toJson writable), + ("session_id", input.getObjValD "session_id"), ("receipt", toJson receipt)]) + -- The adapter acknowledges only after emitting a supported host response. +def acknowledgeDelivery (input : Json) : IO Unit := do + let _ ← Daemon.boundedRpc "ack" input ((← IO.monoMsNow) + 2000) + pure () + +end Eggshell.Adapter.Bridge diff --git a/adapters/native/Adapter/ContractAudit.lean b/adapters/native/Adapter/ContractAudit.lean new file mode 100644 index 0000000..aa0f062 --- /dev/null +++ b/adapters/native/Adapter/ContractAudit.lean @@ -0,0 +1,32 @@ +module + +import Adapter.Protocol +public meta import Lean + +open Lean + +run_meta do + let contracts := #[ + ``Eggshell.Adapter.stop_is_quiet, + ``Eggshell.Adapter.interrupt_is_quiet, + ``Eggshell.Adapter.finish_is_quiet, + ``Eggshell.Adapter.unsupported_cursor_prompt_is_quiet, + ``Eggshell.Adapter.quiet_never_acknowledged, + ``Eggshell.Adapter.accepted_owner_is_exact, + ``Eggshell.Adapter.different_host_rejected, + ``Eggshell.Adapter.different_chat_rejected, + ``Eggshell.Adapter.terminal_is_journaled_first, + ``Eggshell.Adapter.no_consumption_without_journal_prefix, + ``Eggshell.Adapter.chosen_call_is_original, + ``Eggshell.Adapter.ambiguous_calls_rejected, + ``Eggshell.Adapter.saved_answer_is_authorized, + ``Eggshell.Adapter.aborted_answer_not_saved, + ``Eggshell.Adapter.unrelated_entry_preserved, + ``Eggshell.Adapter.removed_entry_was_owned, + ``Eggshell.Adapter.stop_wire_is_empty] + for contract in contracts do + let axioms ← Lean.collectAxioms contract + for dependency in axioms do + unless #[``propext, ``Quot.sound, ``Classical.choice].contains dependency do + throwError "{contract} depends on unapproved axiom {dependency}" + logInfo m!"Audited {contract}: {axioms}" diff --git a/adapters/native/Adapter/Contracts.lean b/adapters/native/Adapter/Contracts.lean new file mode 100644 index 0000000..6126948 --- /dev/null +++ b/adapters/native/Adapter/Contracts.lean @@ -0,0 +1,198 @@ +module + +public import Lean + +@[expose] public section + +namespace Eggshell.Adapter + +inductive Host where + | claude | gemini | cursor | opencode + deriving BEq, DecidableEq, Repr, Lean.ToJson, Lean.FromJson + +def Host.name : Host → String + | .claude => "claude" + | .gemini => "gemini" + | .cursor => "cursor" + | .opencode => "opencode" + +def Host.parse (name : String) : Except String Host := + match name with + | "claude" => .ok .claude + | "gemini" => .ok .gemini + | "cursor" => .ok .cursor + | "opencode" => .ok .opencode + | _ => .error "expected claude, gemini, cursor, or opencode" + +inductive Event where + | start | prompt | before | after | answer | stop | interrupt | compact | finish + deriving BEq, DecidableEq, Repr + +def Event.engineName : Event → String + | .start => "SessionStart" + | .prompt => "UserPromptSubmit" + | .before => "PreToolUse" + | .after => "PostToolUse" + | .answer => "AssistantMessage" + | .stop => "Stop" + | .interrupt => "Interrupt" + | .compact => "PostCompact" + | .finish => "SessionEnd" + +def events : Host → List (String × Event) + | .claude => [("SessionStart", .start), ("UserPromptSubmit", .prompt), + ("PreToolUse", .before), ("PostToolUse", .after), ("PostToolUseFailure", .after), + ("Stop", .stop), ("StopFailure", .interrupt), ("SessionEnd", .finish)] + | .gemini => [("SessionStart", .start), ("BeforeAgent", .prompt), + ("BeforeTool", .before), ("AfterTool", .after), ("AfterAgent", .stop), + ("PreCompress", .compact), ("SessionEnd", .finish)] + | .cursor => [("sessionStart", .start), ("beforeSubmitPrompt", .prompt), + ("preToolUse", .before), ("postToolUse", .after), ("postToolUseFailure", .after), + ("afterAgentResponse", .answer), ("stop", .stop), ("preCompact", .compact), + ("sessionEnd", .finish)] + | .opencode => [("SessionStart", .start), ("UserPromptSubmit", .prompt), + ("PreToolUse", .before), ("PostToolUse", .after), ("PostCompact", .compact), + ("Stop", .stop), ("Interrupt", .interrupt), ("SessionEnd", .finish)] + +def contextAllowed : Host → Event → Bool + | .claude, .prompt | .claude, .before | .claude, .after => true + | .gemini, .prompt | .gemini, .after => true + | .cursor, .after => true + | .opencode, .prompt | .opencode, .after => true + | _, _ => false + +/-- This is the entire host-output vocabulary. There is no approval or retry. -/ +inductive Reply where + | quiet + | context (text : String) + | deny (reason : String) + deriving BEq, DecidableEq, Repr + +def projectReply (host : Host) (event : Event) (context denial : Option String) : Reply := + if event == .before then + match denial with + | some reason => .deny reason + | none => if contextAllowed host event then context.map Reply.context |>.getD .quiet else .quiet + else if contextAllowed host event then context.map Reply.context |>.getD .quiet else .quiet + +theorem stop_is_quiet (h : Host) (c d : Option String) : + projectReply h .stop c d = .quiet := by cases h <;> rfl + +theorem interrupt_is_quiet (h : Host) (c d : Option String) : + projectReply h .interrupt c d = .quiet := by cases h <;> rfl + +theorem finish_is_quiet (h : Host) (c d : Option String) : + projectReply h .finish c d = .quiet := by cases h <;> rfl + +theorem unsupported_cursor_prompt_is_quiet (c d : Option String) : + projectReply .cursor .prompt c d = .quiet := rfl + +/-- A receipt exists in the acknowledge phase only after publishing completed. + The IO interpreter constructs this value after its output write and flush. -/ +structure Published where + receipt : String + +def receiptToAck (reply : Reply) (published : Published) : Option String := + match reply with + | .quiet => none + | .context _ | .deny _ => some published.receipt + +theorem quiet_never_acknowledged (p : Published) : receiptToAck .quiet p = none := rfl + +structure Identity where + host : Host + native : String + deriving BEq, DecidableEq, Repr, Lean.ToJson, Lean.FromJson + +/-- Hashes locate a file; the full identity authorizes access to its contents. -/ +def ownerMatches (stored requested : Identity) : Bool := decide (stored = requested) + +theorem accepted_owner_is_exact (a b : Identity) (h : ownerMatches a b = true) : a = b := by + simpa [ownerMatches] using h + +theorem different_host_rejected (a b : Identity) (h : a.host ≠ b.host) : + ownerMatches a b = false := by + simp only [ownerMatches, decide_eq_false_iff_not] + intro equal + exact h (congrArg Identity.host equal) + +theorem different_chat_rejected (a b : Identity) (h : a.native ≠ b.native) : + ownerMatches a b = false := by + simp only [ownerMatches, decide_eq_false_iff_not] + intro equal + exact h (congrArg Identity.native equal) + +inductive CommitStep where + | journal | consume + deriving BEq, DecidableEq + +def commitPlan (terminal : Bool) : List CommitStep := + if terminal then [.journal, .consume] else [.consume] + +theorem terminal_is_journaled_first : commitPlan true = [.journal, .consume] := rfl + +theorem no_consumption_without_journal_prefix (steps : List CommitStep) + (h : steps = commitPlan true) : + ∃ rest, steps = .journal :: rest ∧ .consume ∈ rest := by + subst steps + exact ⟨[.consume], rfl, by simp⟩ + +structure Call where + id : String + native : String + turn : String + finished : Bool := false + writable : Bool := false + deriving BEq, DecidableEq, Repr, Lean.ToJson, Lean.FromJson + +/-- An attributed result carries a proof of origin, not a guessed current turn. -/ +def chooseCall (calls : List Call) : Option { c : Call // + c ∈ calls ∧ ∀ other ∈ calls, other.turn = c.turn } := + match calls with + | [] => none + | head :: tail => + if h : ∀ other ∈ head :: tail, other.turn = head.turn then + some ⟨head, by simp, h⟩ + else none + +theorem chosen_call_is_original (calls : List Call) + (selected : { c : Call // c ∈ calls ∧ ∀ other ∈ calls, other.turn = c.turn }) : + selected.val ∈ calls := selected.property.1 + +theorem ambiguous_calls_rejected (calls : List Call) (a b : Call) + (ha : a ∈ calls) (hb : b ∈ calls) (different : a.turn ≠ b.turn) : + chooseCall calls = none := by + cases calls with + | nil => simp at ha + | cons head tail => + simp only [chooseCall] + split + next all => exact False.elim (different ((all a ha).trans (all b hb).symm)) + next => rfl + +def maySaveAnswer (enabled writable active completed : Bool) : Bool := + enabled && writable && active && completed + +theorem saved_answer_is_authorized (e w o c : Bool) (h : maySaveAnswer e w o c = true) : + e = true ∧ w = true ∧ o = true ∧ c = true := by + simpa [maySaveAnswer, and_assoc] using h + +theorem aborted_answer_not_saved (e w o : Bool) : maySaveAnswer e w o false = false := by + simp [maySaveAnswer] + +/-- Configuration editing uses these exact functions on serialized entries. -/ +def removeOwned (owned entries : List String) : List String := + entries.filter fun entry => !owned.contains entry + +theorem unrelated_entry_preserved (owned entries : List String) (entry : String) + (present : entry ∈ entries) (unowned : entry ∉ owned) : + entry ∈ removeOwned owned entries := by + simp [removeOwned, present, unowned] + +theorem removed_entry_was_owned (owned entries : List String) (entry : String) + (present : entry ∈ entries) (removed : entry ∉ removeOwned owned entries) : entry ∈ owned := by + by_cases member : entry ∈ owned + · exact member + · exact False.elim (removed (unrelated_entry_preserved owned entries entry present member)) + +end Eggshell.Adapter diff --git a/adapters/native/Adapter/Install.lean b/adapters/native/Adapter/Install.lean new file mode 100644 index 0000000..91e57e6 --- /dev/null +++ b/adapters/native/Adapter/Install.lean @@ -0,0 +1,167 @@ +module + +public import Adapter.Protocol + +@[expose] public section + +namespace Eggshell.Adapter.Install +open Lean Eggshell.Plugin + +def owner : String := "momonpya/eggshell-adapters-v1\n" +def openCodeSource : String := include_str "../../opencode.mjs" + +def shellQuote (text : String) : String := "'" ++ text.replace "'" "'\\''" ++ "'" + +def configPath : Host → String + | .claude => ".claude/settings.json" + | .gemini => ".gemini/settings.json" + | .cursor => ".cursor/hooks.json" + | .opencode => ".opencode/plugins/eggshell.js" + +def readDocument (path : System.FilePath) : IO Json := do + if !(← path.pathExists) then return Json.mkObj [] + let json ← IO.ofExcept (Json.parse (← IO.FS.readFile path)) + let _ ← IO.ofExcept json.getObj? + pure json + +def fields (document : Json) (key : String) : Except String Json := do + match document.getObjVal? key with + | .error _ => pure (Json.mkObj []) + | .ok json => let _ ← json.getObj?; pure json + +def arrayField (document : Json) (key : String) : Except String (Array Json) := + match document.getObjVal? key with + | .error _ => .ok #[] + | .ok json => json.getArr? + +def eraseField (document : Json) (key : String) : Except String Json := do + pure (Json.mkObj ((← document.getObj?).toList.filter (·.1 != key))) + +def removeEntries (document entries : Json) : Except String Json := do + let mut hooks ← fields document "hooks" + for (event, entry) in (← entries.getObj?).toList do + let current ← arrayField hooks event + let kept := removeOwned [entry.compress] (current.toList.map Json.compress) + if kept.isEmpty then hooks ← eraseField hooks event + else hooks := hooks.setObjVal! event (.arr ((← kept.mapM Json.parse).toArray)) + if (← hooks.getObj?).isEmpty then eraseField document "hooks" + else pure (document.setObjVal! "hooks" hooks) + +def entries (host : Host) (command : String) : Json := + Json.mkObj ((events host).map fun (event, _) => + let handler := Json.mkObj ([("type", .str "command"), ("command", .str command), + ("timeout", toJson (if host == .gemini then 35000 else 35 : Nat))] ++ + if host == .gemini then [("name", .str ("eggshell-" ++ event))] else []) + (event, if host == .cursor then handler else Json.mkObj [("hooks", .arr #[handler])])) + +def addEntries (document selected : Json) : Except String Json := do + let mut hooks ← fields document "hooks" + for (event, entry) in (← selected.getObj?).toList do + let current ← arrayField hooks event + hooks := hooks.setObjVal! event (.arr (current.push entry)) + pure (document.setObjVal! "hooks" hooks) + +/-- A write-ahead receipt retains both versions until the project config is + replaced. A crash between files cannot orphan the previous owned hooks. -/ +def receiptHistory (receipt : Json) (fuel : Nat := 1024) : Except String (List Json) := do + match fuel with + | 0 => throw "adapter receipt nesting is excessive" + | n + 1 => + match receipt.getObjVal? "previous" with + | .error _ => pure [receipt] + | .ok previous => pure (receipt :: (← receiptHistory previous n)) + +def atomicBytes (path : System.FilePath) (bytes : ByteArray) (executable := false) : IO Unit := do + Persistence.rejectSymlinkAncestors path + if let some parent := path.parent then IO.FS.createDirAll parent + let temporary := System.FilePath.mk (path.toString ++ ".tmp-" ++ Blake3.hex (← IO.getRandomBytes 16)) + try + IO.FS.writeBinFile temporary bytes + IO.setAccessRights temporary { user := { read := true, write := true, execution := executable } } + IO.FS.rename temporary path + finally removeIfExists temporary + +def writeDocument (path : System.FilePath) (value : Json) : IO Unit := + atomicBytes path (value.pretty ++ "\n").toUTF8 + +def fileUrl (path : System.FilePath) : String := + "file://" ++ String.ofList (path.toString.toUTF8.data.toList.flatMap fun byte => + if byte.toNat == 47 || byte.toNat == 45 || byte.toNat == 46 || byte.toNat == 95 || + (byte.toNat ≥ 48 && byte.toNat ≤ 57) || (byte.toNat ≥ 65 && byte.toNat ≤ 90) || + (byte.toNat ≥ 97 && byte.toNat ≤ 122) then [Char.ofNat byte.toNat] + else ('%' :: (Blake3.hex (ByteArray.mk #[byte])).toList)) + +def run (host : Host) (project runtimeRoot : System.FilePath) (uninstall : Bool) : IO Json := do + if !(← project.isDir) then throw (IO.userError "project directory does not exist") + let project ← IO.FS.realPath project + let support := runtimeRoot / "share" / "eggshell-adapters" + let marker := support / ".owner" + Persistence.rejectSymlinkAncestors support + if ← support.pathExists then + if !(← marker.pathExists) || (← IO.FS.readFile marker) != owner then + throw (IO.userError "refusing to replace an unowned adapter directory") + let config := project / configPath host + Persistence.rejectSymlinkAncestors config + let key := Sha256.hex (host.name ++ "\n" ++ config.toString).toUTF8 + let receiptPath := support / "receipts" / (key ++ ".json") + let previous ← readDocument receiptPath + let history ← IO.ofExcept (receiptHistory previous) + let old ← if ← config.pathExists then some <$> IO.FS.readFile config else pure none + let document ← if host == .opencode then + if old.isSome && !(history.any (fun receipt => old == optionalString receipt "contents")) then + throw (IO.userError "refusing to replace an unowned OpenCode plugin") + pure (Json.mkObj []) + else + let document ← readDocument config + if host == .cursor && (document.getObjVal? "version").isOk && document.getObjValD "version" != toJson (1 : Nat) then + throw (IO.userError "unsupported Cursor hooks schema version") + history.foldlM (fun current receipt => do + IO.ofExcept (removeEntries current (← IO.ofExcept (fields receipt "entries")))) document + if uninstall then + if !(← receiptPath.pathExists) then return Json.mkObj [("status", .str "not-installed")] + if host == .opencode then removeIfExists config else writeDocument config document + removeIfExists receiptPath + return Json.mkObj [("status", .str "removed"), ("memory", .str "preserved"), ("runtime", .str "preserved")] + let installed := support / "eggshell-bridge" + let command := String.intercalate " " (["env", "EGGSHELL_PREFIX=" ++ runtimeRoot.toString, + installed.toString, "hook", host.name].map shellQuote) + let selected := entries host command + let final ← IO.ofExcept (addEntries document selected) + Persistence.privateDirectory support + atomicBytes marker owner.toUTF8 + let executable ← IO.appPath + if executable != installed then atomicBytes installed (← IO.FS.readBinFile executable) true + atomicBytes (support / "opencode.mjs") openCodeSource.toUTF8 + if host == .opencode then + let contents := "// Eggshell project adapter.\nexport { Eggshell } from " ++ + (Json.str (fileUrl (support / "opencode.mjs"))).compress ++ ";\n" + let receipt := Json.mkObj [("contents", .str contents)] + writeDocument receiptPath (receipt.setObjVal! "previous" previous) + atomicBytes config contents.toUTF8 + writeDocument receiptPath receipt + else + let final := if host == .cursor then final.setObjVal! "version" (toJson (1 : Nat)) else final + let receipt := Json.mkObj [("entries", selected)] + writeDocument receiptPath (receipt.setObjVal! "previous" previous) + writeDocument config final + writeDocument receiptPath receipt + return Json.mkObj [("status", .str "configured"), ("client", .str host.name), ("config", .str config.toString)] + +def command (host : Host) (args : List String) : IO UInt32 := do + let rec options (project runtimeRoot : System.FilePath) (remove : Bool) : List String → Except String _ + | [] => .ok (project, runtimeRoot, remove) + | "--project" :: path :: rest => options (.mk path) runtimeRoot remove rest + | "--prefix" :: path :: rest => options project (.mk path) remove rest + | "--uninstall" :: rest => options project runtimeRoot true rest + | _ => .error "expected --project PATH, --prefix PATH, or --uninstall" + let (project, runtimeRoot, remove) ← IO.ofExcept (options (← IO.currentDir) (← Paths.installRoot) false args) + if !project.isAbsolute || !runtimeRoot.isAbsolute then throw (IO.userError "project and runtimeRoot must be absolute") + Persistence.privateDirectory runtimeRoot + let lock ← IO.FS.Handle.mk (runtimeRoot / ".eggshell-adapter-install.lock") .append + lock.lock + try + IO.println (← run host project runtimeRoot remove).pretty + pure 0 + finally lock.unlock + +end Eggshell.Adapter.Install diff --git a/adapters/native/Adapter/Protocol.lean b/adapters/native/Adapter/Protocol.lean new file mode 100644 index 0000000..e234daa --- /dev/null +++ b/adapters/native/Adapter/Protocol.lean @@ -0,0 +1,167 @@ +module + +public import Adapter.Contracts +public import Eggshell.Sha256 +public import Eggshell.PluginModel +public import Eggshell.PluginHooks + +@[expose] public section + +namespace Eggshell.Adapter +open Lean Eggshell.Plugin + +def sessionKey (identity : Identity) : String := + identity.host.name ++ "-" ++ Sha256.hex identity.native.toUTF8 + +structure Receipt where + stamp : String + call : Call + deriving Repr, ToJson, FromJson + +structure Correlation where + owner : Identity + turn : Option String := none + calls : List Call := [] + receipts : List Receipt := [] + deriving Repr, ToJson, FromJson + +def requiredText (raw : Json) (key : String) : Except String String := do + let value ← requiredString raw key + if value.isEmpty then throw ("hook must provide nonempty " ++ key) + pure value + +def optionalText (raw : Json) (key : String) : Except String (Option String) := do + if !(raw.getObjVal? key).isOk || raw.getObjValD key == .null then return none + some <$> requiredText raw key + +def identify (host : Host) (raw : Json) : Except String Identity := do + pure ⟨host, ← requiredText raw (if host == .cursor then "conversation_id" else "session_id")⟩ + +def directory (raw : Json) : Except String String := do + let cwd := optionalString raw "cwd" |>.filter (!·.isEmpty) + let cwd ← match cwd with + | some value => pure value + | none => match raw.getObjValD "workspace_roots" with + | .arr #[.str path] => pure path + | _ => throw "hook must identify one project working directory" + if !(System.FilePath.mk cwd).isAbsolute then throw "hook working directory must be absolute" + pure cwd + +def nativeEvent (host : Host) (raw : Json) : Option Event := + (events host).find? (fun pair => some pair.1 == optionalString raw "hook_event_name") |>.map (·.2) + +def bindCall (state : Correlation) (id native turn : String) : Except String Correlation := do + match state.calls.find? (·.id == id) with + | some old => + if old.turn != turn then throw "native tool ID was reused across different turns" + return state + | none => return { state with calls := state.calls ++ [⟨id, native, turn, false, false⟩] } + +/-- Canonical JSON objects are sorted by Lean's JSON representation. -/ +def signature (name : String) (args : Json) : String := + Sha256.hex (Json.arr #[.str name, args] |>.compress.toUTF8) + +inductive Normalized where + | event (input : Json) (state : Correlation) + | unattributed (input : Json) (recordable : Bool) + +def normalize (host : Host) (event : Event) (raw : Json) (state : Correlation) + (fresh : String) : Except String Normalized := do + let identity ← identify host raw + if !ownerMatches state.owner identity then throw "adapter session identity mismatch" + let cwd ← directory raw + let mut result := Json.mkObj [("session_id", toJson (sessionKey identity)), + ("cwd", toJson cwd), ("hook_event_name", toJson event.engineName)] + for key in ["source", "prompt", "tool_name", "tool_input", "tool_response"] do + if let .ok value := raw.getObjVal? key then result := result.setObjVal! key value + let explicitTurn ← optionalText raw (if host == .cursor then "generation_id" else "turn_id") + if event == .prompt then + let _ ← requiredText raw "prompt" + let turn := explicitTurn.getD fresh + return .event (result.setObjVal! "turn_id" (toJson turn)) { state with turn := some turn } + let turn := explicitTurn.or state.turn + if let some turn := turn then result := result.setObjVal! "turn_id" (toJson turn) + let mut state := state + if event == .before || event == .after then + let name ← requiredText raw "tool_name" + let args ← raw.getObjVal? "tool_input" + let explicitCall ← optionalText raw "tool_use_id" + let native := explicitCall.getD (signature name args) + let stamp := (optionalString raw "timestamp" |>.filter (!·.isEmpty)).map fun time => + Sha256.hex (Json.arr #[raw.getObjValD "hook_event_name", .str native, .str time] |>.compress.toUTF8) + if event == .before then + let some turn := turn | throw "tool arrived before a user turn" + let id := explicitCall.or stamp |>.getD fresh + state ← bindCall state id native turn + result := result.setObjVal! "tool_use_id" (toJson id) + else + let failed := ["PostToolUseFailure", "postToolUseFailure"].contains + ((optionalString raw "hook_event_name").getD "") + let response ← if failed then pure (Json.mkObj [("error", raw.getObjValD "error"), ("is_error", .bool true)]) + else if host == .cursor then + let response := raw.getObjValD "tool_output" + pure <| match response with + | .str text => (Json.parse text).toOption.getD response + | _ => response + else raw.getObjVal? "tool_response" + result := result.setObjVal! "tool_response" response + let previous := stamp.bind fun stamp => state.receipts.find? (·.stamp == stamp) + let chosen ← match previous with + | some receipt => pure (some receipt.call) + | none => + let eligible := state.calls.filter fun c => + (match explicitCall with + | some id => c.id == id + | none => c.native == native && !c.finished) && + (explicitTurn.all (· == c.turn)) + if eligible.isEmpty && explicitCall.isSome && explicitTurn.isSome then + let id := explicitCall.getD "" + let origin := explicitTurn.getD "" + state ← bindCall state id native origin + pure (state.calls.find? (·.id == id)) + else pure ((chooseCall eligible).map (·.val)) + let some call := chosen | + let candidates := state.calls.filter (·.native == native) + let body := Json.mkObj ((result.getObj?.toOption.map (·.toList) |>.getD []) + |>.filter (·.1 != "turn_id")) + return .unattributed body (!candidates.isEmpty && candidates.all (·.writable)) + state := { state with calls := state.calls.map fun c => + if c.id == call.id then { c with finished := true } else c } + if let some stamp := stamp then + if !(state.receipts.any (·.stamp == stamp)) then + state := { state with receipts := state.receipts ++ [⟨stamp, call⟩] } + result := result.setObjVal! "tool_use_id" (toJson call.id) + |>.setObjVal! "turn_id" (toJson call.turn) + if event == .stop then + if let some text := optionalString raw (if host == .gemini then "prompt_response" else "last_assistant_message") then + result := result.setObjVal! "last_assistant_message" (toJson text) + return .event result state + +def proposal (host : Host) (event : Event) (output : Json) : Reply := + let fields := output.getObjValD "hookSpecificOutput" + let context := optionalString fields "additionalContext" |>.filter (!·.isEmpty) + let denial := if optionalString fields "permissionDecision" == some "deny" then + optionalString fields "permissionDecisionReason" |>.filter (!·.isEmpty) else none + projectReply host event context denial + +def replyJson (host : Host) (event : Event) : Reply → Json + | .quiet => Json.mkObj [] + | .deny reason => + match host with + | .claude | .opencode => Json.mkObj [("hookSpecificOutput", Json.mkObj [ + ("hookEventName", .str "PreToolUse"), ("permissionDecision", .str "deny"), + ("permissionDecisionReason", .str reason)])] + | .gemini => Json.mkObj [("decision", .str "deny"), ("reason", .str reason)] + | .cursor => Json.mkObj [("permission", .str "deny"), ("agent_message", .str reason)] + | .context text => + if host == .cursor then Json.mkObj [("additional_context", .str text)] + else Json.mkObj [("hookSpecificOutput", Json.mkObj [ + ("hookEventName", .str (if host == .gemini then + if event == .prompt then "BeforeAgent" else "AfterTool" else event.engineName)), + ("additionalContext", .str text)])] + +theorem stop_wire_is_empty (host : Host) (output : Json) : + replyJson host .stop (proposal host .stop output) = Json.mkObj [] := by + simp [proposal, stop_is_quiet, replyJson] + +end Eggshell.Adapter diff --git a/adapters/native/Adapter/Runtime.lean b/adapters/native/Adapter/Runtime.lean new file mode 100644 index 0000000..ec58d24 --- /dev/null +++ b/adapters/native/Adapter/Runtime.lean @@ -0,0 +1,150 @@ +module + +public import Adapter.Protocol +public import Adapter.Bridge + +@[expose] public section + +namespace Eggshell.Adapter +open Lean Eggshell.Plugin + +def correlationRoot : IO System.FilePath := do + pure ((← Paths.dataRoot) / "adapters") + +def freshId : IO String := do + pure (Blake3.hex (← IO.getRandomBytes 16)) + +/-- The reducer is pure. Authorized terminal receipts are journaled before + committing consumed call IDs. No RPC, model, or search runs under this lock. -/ +def transaction (identity : Identity) + (reduce : Correlation → Except String (α × Correlation)) + (journal : α → Option Json := fun _ => none) : IO α := do + let root ← correlationRoot + Persistence.privateDirectory root + let file := root / (sessionKey identity ++ ".json") + Persistence.rejectSymlinkAncestors file + let lockPath := Persistence.lockPath file + let guard ← IO.FS.Handle.mk lockPath .append + Persistence.privateFile lockPath + guard.lock + try + if !(← file.pathExists) && (← (root / (sessionKey identity ++ ".sqlite3")).pathExists) then + throw (IO.userError "this chat has legacy adapter correlation state; start a new chat after upgrading (saved eggs are preserved)") + let state := (← readJson? file : Option Correlation).getD { owner := identity } + if !ownerMatches state.owner identity then throw (IO.userError "adapter session identity mismatch") + let (result, next) ← IO.ofExcept (reduce state) + if !ownerMatches next.owner identity then throw (IO.userError "adapter reducer changed session identity") + let terminal := journal result + for step in commitPlan terminal.isSome do + match step with + | .journal => + if let some input := terminal then + captureTerminal input + Bridge.captureLateResult input + | .consume => writeJson file next + pure result + finally guard.unlock + +def emit (value : Json) : IO Unit := do + let stdout ← IO.getStdout + stdout.putStrLn value.compress + stdout.flush + +/-- The returned capability is created after write+flush, never before them. -/ +def publish (value : Json) (receipt : String) : IO Published := do + emit value + pure ⟨receipt⟩ + +def writableNow (input : Json) : IO Bool := do + let session ← IO.ofExcept (requiredText input "session_id") + withSession session fun files => do + let state ← readState? files + let pending ← readPendingBase? files + pure (state.any (·.enabled) && pending.any (·.write.isSome)) + +def retainUnattributed (input : Json) (permitted : Bool) : IO Unit := do + if !permitted || !(← writableNow input) then return + let session ← IO.ofExcept (requiredText input "session_id") + let root ← correlationRoot + writeJson (root / "unattributed" / session / ((← freshId) ++ ".json")) input + +def runHook (host : Host) (raw : Json) : IO Unit := do + let some event := nativeEvent host raw | emit (Json.mkObj []) + let identity ← IO.ofExcept (identify host raw) + let fresh ← freshId + let normalized ← transaction identity (journal := fun result => + match result with + | .event input _ => if event == .after then some input else none + | .unattributed .. => none) fun state => do + let result ← normalize host event raw state fresh + pure (result, match result with | .event _ next => next | .unattributed .. => state) + let input ← match normalized with + | .event input _ => pure input + | .unattributed input recordable => + retainUnattributed input recordable + IO.eprintln "Eggshell: ambiguous result retained only when authorized; no task was invented" + emit (Json.mkObj []) + return + if host == .cursor && event == .answer then + if let some text := optionalString raw "text" then + Bridge.draftAnswer (input.setObjVal! "text" (toJson text)) + return ← emit (Json.mkObj []) + let input := if host == .cursor && event == .stop then + input.setObjVal! "_adapter_use_draft" (toJson (optionalString raw "status" == some "completed")) + else input + let receipt ← Bridge.deliver input + let reply := proposal host event (receipt.getObjValD "output") + if event == .before then + let id := optionalString input "tool_use_id" |>.getD "" + let denied := match reply with | .deny _ => true | _ => false + let writable := receipt.getObjValD "writable" == true + transaction identity fun state => pure ((), { state with calls := state.calls.map fun call => + if call.id == id then { call with finished := denied, writable } else call }) + let wire := replyJson host event reply + let id ← IO.ofExcept (requiredText receipt "receipt") + if host == .opencode then + emit (Json.mkObj [("output", wire), ("receipt", .str id), + ("session_id", .str (sessionKey identity))]) + else + let published ← publish wire id + if let some acknowledged := receiptToAck reply published then + -- Output has already been written. A receipt error must never write a + -- second JSON object into the host response. + try Bridge.acknowledgeDelivery (Json.mkObj [ + ("session_id", .str (sessionKey identity)), ("receipt", .str acknowledged)]) + catch error => IO.eprintln s!"Eggshell delivery receipt: {error}" + +def hook (host : Host) : IO UInt32 := do + try + runHook host (← IO.ofExcept (Json.parse (← (← IO.getStdin).readToEnd))) + catch error => + IO.eprintln s!"Eggshell adapter: {error}" + emit (Json.mkObj []) + pure 0 + +def acknowledge (host : Host) : IO UInt32 := do + let input ← IO.ofExcept (Json.parse (← (← IO.getStdin).readToEnd)) + let session ← IO.ofExcept (requiredText input "session_id") + if !(session.startsWith (host.name ++ "-")) then throw (IO.userError "receipt belongs to another harness") + Bridge.acknowledgeDelivery input + pure 0 + +/-- Controls execute in a fresh native process with the explicit adapter chat. + Inherited Codex plugin discovery cannot select a different manager binary. -/ +def control (host : Host) (session : String) (args : List String) : IO UInt32 := do + if args == ["doctor"] then + let output ← IO.Process.output { + cmd := (← IO.appPath).toString + args := #["egg", "doctor"] + env := #[("CODEX_THREAD_ID", some (sessionKey ⟨host, session⟩)), ("PLUGIN_ROOT", none)] } + if output.exitCode != 0 then throw (IO.userError output.stderr) + let report ← IO.ofExcept (Json.parse output.stdout) + emit (report.setObjVal! "hook_trust" (.str ("Review hooks in " ++ host.name)) + |>.setObjVal! "next_step" (.str "Restart the agent and verify saving and delivery with the two-chat example.")) + return 0 + let child ← IO.Process.spawn { + cmd := (← IO.appPath).toString, args := ("egg" :: args).toArray + env := #[("CODEX_THREAD_ID", some (sessionKey ⟨host, session⟩)), ("PLUGIN_ROOT", none)] } + child.wait + +end Eggshell.Adapter diff --git a/adapters/native/Main.lean b/adapters/native/Main.lean index 653f709..7e77c87 100644 --- a/adapters/native/Main.lean +++ b/adapters/native/Main.lean @@ -1,142 +1,36 @@ module -public import Eggshell.Daemon +public import Adapter.Runtime +public import Adapter.Install +public import Eggshell.SearchProvider @[expose] public section -open Lean Eggshell Eggshell.Plugin +open Lean Eggshell Eggshell.Plugin Eggshell.Adapter -def draftPath (files : SessionFiles) (turn : String) : System.FilePath := - files.directory / "adapter-drafts" / - (Blake3.hex (Blake3.digest "eggshell.turn".toUTF8 [turn.toUTF8]) ++ ".json") - -def turnPath (files : SessionFiles) (turn : String) : System.FilePath := - files.directory / "adapter-turns" / - (Blake3.hex (Blake3.digest "eggshell.turn".toUTF8 [turn.toUTF8]) ++ ".json") - -/-- Retain the engine's original write authorization for late terminal hooks. - This is an immutable engine value, not a second graph or search policy. -/ -def rememberTurn (input : Json) : IO Unit := do - if optionalString input "hook_event_name" != some "UserPromptSubmit" then return - let session ← IO.ofExcept (requiredString input "session_id") - let turn ← IO.ofExcept (requiredString input "turn_id") - withSession session fun files => do - let some state ← readState? files | return - let some pending ← readPendingBase? files | return - if !state.enabled || pending.turnId != turn || pending.write.isNone then return - writeJson (turnPath files turn) { pending with tools := [], inFlight := [] } - -/-- A tool started under an earlier turn keeps that turn's write target even - when its terminal hook arrives after the engine has sealed/deferred it. -/ -def captureLateResult (input : Json) : IO Unit := do - if optionalString input "hook_event_name" != some "PostToolUse" then return - let session ← IO.ofExcept (requiredString input "session_id") - let turn ← IO.ofExcept (requiredString input "turn_id") - withSession session fun files => do - let some state ← readState? files | return - if !state.enabled then return - let current ← readPendingBase? files - if current.any (fun pending => pending.turnId == turn && !pending.closed && pending.finalMessage.isNone) then return - let some original ← (readJson? (turnPath files turn) : IO (Option PendingTurn)) | return - let tool ← IO.ofExcept (toolFromHook input true) - queueCheckpoint files { - original with tools := [tool], inFlight := [], finalMessage := none, closed := false } - -/-- Some hosts report answer text before loop completion. A candidate is local - adapter state, never a final engine outcome. Respect the selected turn's - write profile, including a one-turn read-only override. -/ -def draftAnswer : IO UInt32 := do - try - let input ← IO.ofExcept (Json.parse (← (← IO.getStdin).readToEnd)) - let session ← IO.ofExcept (requiredString input "session_id") - let turn ← IO.ofExcept (requiredString input "turn_id") - withSession session fun files => do - let some state ← readState? files | return - let some pending ← readPendingBase? files | return - if !state.enabled || pending.turnId != turn || pending.closed || pending.write.isNone then return - let text ← IO.ofExcept (requiredString input "text") - writeJson (draftPath files turn) text - IO.println "{}" - pure 0 - catch error => - IO.eprintln s!"Eggshell adapter draft: {error}" - IO.println "{}" - pure 0 - -def attachDraft (input : Json) : IO Json := do - if optionalString input "hook_event_name" != some "Stop" then return input - let session ← IO.ofExcept (requiredString input "session_id") - let some turn := optionalString input "turn_id" | return input - withSession session fun files => do - let path := draftPath files turn - let state ← readState? files - let pending ← readPendingBase? files - let permitted := state.any (·.enabled) && pending.any fun pending => - pending.turnId == turn && pending.write.isSome && !pending.closed - let text : Option String ← if permitted && input.getObjValD "_adapter_use_draft" == true then - readJson? path - else pure none - removeIfExists path - pure (text.map (fun text => input.setObjVal! "last_assistant_message" (toJson text)) |>.getD input) - -/-- A separate companion executable. The memory engine and its Codex entrypoint - are imported unchanged; host-specific schemas live outside this package. -/ -def deliver : IO UInt32 := do +def main (args : List String) : IO UInt32 := do try - let raw ← IO.ofExcept (Json.parse (← (← IO.getStdin).readToEnd)) - let input ← attachDraft (Daemon.attachClientConfig raw (← IO.getEnv "EGGSHELL_CONFIG")) - -- Use the engine's durable capture before any manager or search operation. - captureTerminal input - captureLateResult input - let event := (optionalString input "hook_event_name").getD "" - let fast := ["Stop", "Interrupt", "PostCompact", "SessionEnd"].contains event || - (event == "SessionStart" && optionalString input "source" == some "compact") - let deadline := (← IO.monoMsNow) + (if fast then 2000 else 26000) - let receipt := Blake3.hex (← IO.getRandomBytes 16) - let input := input.setObjVal! "_eggshell_deadline" (toJson (deadline - 250)) - |>.setObjVal! "_eggshell_receipt" (toJson receipt) - let result ← Daemon.boundedRpc "hook" input (deadline - 150) - rememberTurn input - let some result := result | - throw (IO.userError "memory hook did not return before its delivery deadline; captured results remain queued") - let reply ← IO.ofExcept (Json.parse result) - if let some error := optionalString reply "error" then throw (IO.userError error) - let output ← IO.ofExcept (Json.parse ((optionalString reply "output").getD "{}")) - let session ← IO.ofExcept (requiredString input "session_id") - let writable ← withSession session fun files => do - let state ← readState? files - let pending ← readPendingBase? files - pure <| state.any (·.enabled) && pending.any fun pending => - optionalString input "turn_id" == some pending.turnId && pending.write.isSome - IO.println (Json.mkObj [("ok", toJson true), ("output", output), - ("writable", toJson writable), - ("session_id", input.getObjValD "session_id"), ("receipt", toJson receipt)] |>.compress) - -- The adapter acknowledges only after emitting a supported host response. - pure 0 + if (← IO.getEnv "PLUGIN_ROOT").isSome then + let child ← IO.Process.spawn { + cmd := (← IO.appPath).toString + args := args.toArray + env := #[("PLUGIN_ROOT", none)] } + return ← child.wait + match args with + | "search-provider" :: options => Eggshell.SearchProvider.run options + | ["hook", host] => hook (← IO.ofExcept (Host.parse host)) + | ["ack", host] => acknowledge (← IO.ofExcept (Host.parse host)) + | "install" :: host :: options => Eggshell.Adapter.Install.command (← IO.ofExcept (Host.parse host)) options + | "control" :: host :: "--session" :: session :: commands => + control (← IO.ofExcept (Host.parse host)) session commands + | ["codex-daemon", session] => Daemon.run session + | ["codex-worker", role] => Worker.run role + | ["codex-rpc", kind] => Daemon.rpcClient kind + | "egg" :: rest => eggControl rest + | ["--help"] => + IO.println "Eggshell adapter bridge: hook HOST | ack HOST | install HOST [--project PATH] [--uninstall] | control HOST --session ID COMMAND" + pure 0 + | _ => throw (IO.userError "invalid adapter command; use --help") catch error => IO.eprintln s!"Eggshell adapter: {error}" - IO.println (Json.mkObj [("ok", toJson false), ("output", Json.mkObj [])] |>.compress) - pure 0 - -def acknowledgeDelivery : IO UInt32 := do - try - let input ← IO.ofExcept (Json.parse (← (← IO.getStdin).readToEnd)) - let _ ← Daemon.boundedRpc "ack" input ((← IO.monoMsNow) + 2000) - pure 0 - catch error => - IO.eprintln s!"Eggshell adapter receipt: {error}" pure 1 - -def main (arguments : List String) : IO UInt32 := do - match arguments with - | ["deliver"] => deliver - | ["ack"] => acknowledgeDelivery - | ["draft"] => draftAnswer - | ["codex-daemon", session] => Daemon.run session - | ["codex-worker", role] => Worker.run role - | ["codex-rpc", kind] => Daemon.rpcClient kind - | "egg" :: rest => eggControl rest - | ["--help"] => - IO.println "Eggshell adapter bridge: deliver | ack | egg COMMAND" - pure 0 - | _ => IO.eprintln "invalid adapter bridge command" *> pure 1 diff --git a/adapters/native/Tests.lean b/adapters/native/Tests.lean new file mode 100644 index 0000000..d8f9893 --- /dev/null +++ b/adapters/native/Tests.lean @@ -0,0 +1,228 @@ +module + +public import Adapter.Runtime +public import Adapter.Install +import Adapter.ContractAudit + +@[expose] public section + +open Lean Eggshell Eggshell.Plugin Eggshell.Adapter + +def check (condition : Bool) (message : String) : IO Unit := + unless condition do throw (IO.userError message) + +def pureTests : IO Unit := do + for (input, expected) in [ + ("", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + ("abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), + ("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1") ] do + check (Sha256.hex input.toUTF8 == expected) "SHA-256 known-answer mismatch" + let identity : Identity := ⟨.gemini, "test"⟩ + let state : Correlation := { owner := identity, turn := some "turn" } + let raw := Json.mkObj [("session_id", .str "test"), ("cwd", .str "/tmp"), + ("hook_event_name", .str "BeforeTool"), ("tool_name", .str "shell"), + ("tool_input", Json.mkObj [("command", .str "cat a")])] + let .event _ first ← IO.ofExcept (normalize .gemini .before raw state "first") | + throw (IO.userError "first tool not normalized") + let .event _ second ← IO.ofExcept (normalize .gemini .before raw first "second") | + throw (IO.userError "second tool not normalized") + check (second.calls.length == 2) "identical operations collapsed" + let after := raw.setObjVal! "hook_event_name" (.str "AfterTool") + |>.setObjVal! "tool_response" (.str "observed") + let .event result finished ← IO.ofExcept (normalize .gemini .after after second "ignored") | + throw (IO.userError "same-turn parallel result not attributed") + check (result.getObjValD "tool_use_id" == .str "first" && finished.calls.head?.any (·.finished)) + "parallel result did not retain its occurrence" + let conflicting := { second with calls := second.calls.map fun call => + if call.id == "second" then { call with turn := "another", writable := true } else { call with writable := true } } + let .unattributed ambiguous true ← IO.ofExcept (normalize .gemini .after after conflicting "ignored") | + throw (IO.userError "cross-turn ambiguity was incorrectly attributed") + check (!(ambiguous.getObjVal? "turn_id").isOk) "ambiguous result got a parent" + let alien := { state with owner := ⟨.claude, "test"⟩ } + check (!(normalize .gemini .before raw alien "id").isOk) "foreign owner was accepted" + for host in [Host.claude, .gemini, .cursor, .opencode] do + for event in [Event.stop, .interrupt, .finish, .compact] do + check (projectReply host event (some "context") (some "deny") == .quiet) "terminal hook was not quiet" + IO.println "Pure adapter contracts: passed (plus kernel-checked universal theorems)" + +structure Fixture where + root : System.FilePath + binary : System.FilePath + +def Fixture.env (f : Fixture) : Array (String × Option String) := #[ + ("EGGSHELL_PREFIX", some (f.root / "runtime").toString), + ("EGGSHELL_DATA_ROOT", some (f.root / "data").toString), + ("EGGSHELL_CONFIG", some (f.root / ".eggshell.toml").toString), + ("PLUGIN_ROOT", none), ("CODEX_THREAD_ID", none)] + +def Fixture.run (f : Fixture) (args : Array String) (input : Option Json := none) : IO Json := do + let output ← IO.Process.output { cmd := f.binary.toString, args, cwd := some f.root, env := f.env } + (input.map Json.compress) + check (output.exitCode == 0) ("command failed: " ++ output.stderr) + if output.stdout.trimAscii.isEmpty then return Json.mkObj [] + IO.ofExcept (Json.parse output.stdout) + +def Fixture.hook (f : Fixture) (host : Host) (event : Event) (session := "chat") + (turn := "turn") (fields : List (String × Json) := []) : IO Json := do + let name := (events host).find? (·.2 == event) |>.map (·.1) |>.getD "" + let base := [("hook_event_name", .str name), ("cwd", .str f.root.toString)] ++ + (if host == .cursor then [("conversation_id", .str session), ("generation_id", .str turn)] + else [("session_id", .str session)] ++ if host == .opencode then [("turn_id", .str turn)] else []) + f.run #["hook", host.name] (some (Json.mkObj (base ++ fields))) + +def Fixture.waitFor (f : Fixture) (marker : String) : IO Unit := do + for _ in [0:320] do + if ← (f.root / "work.egg").pathExists then + if ((← IO.FS.readFile (f.root / "work.egg")).splitOn marker).length > 1 then return + IO.sleep 25 + throw (IO.userError ("missing saved outcome: " ++ marker)) + +def Fixture.state (f : Fixture) (host : Host) (session := "chat") : IO Json := do + IO.ofExcept (Json.parse (← IO.FS.readFile (f.root / "data" / "sessions" / + sessionKey ⟨host, session⟩ / "state.json"))) + +def Fixture.control (f : Fixture) (host : Host) (session : String) (args : Array String) : IO Unit := do + let result ← IO.Process.output { + cmd := f.binary.toString + args := #["control", host.name, "--session", session] ++ args + cwd := some f.root + env := f.env } + check (result.exitCode == 0) result.stderr + +def Fixture.answerDrafts (f : Fixture) (session : String) : IO Bool := do + let path := f.root / "data" / "sessions" / sessionKey ⟨.cursor, session⟩ / "adapter-drafts" + if !(← path.isDir) then return false + return !(← path.readDir).isEmpty + +def moreLifecycleTests (f : Fixture) : IO Unit := do + for session in ["aborted", "completed", "private", "off"] do + let _ ← f.hook .cursor .start session + if session == "private" then f.control .cursor session #["next", "private"] + let _ ← f.hook .cursor .prompt session "turn" [("prompt", .str "Investigate a clock edge case")] + if session == "off" then f.control .cursor session #["off"] + let marker := "FINAL_" ++ session + let _ ← f.hook .cursor .answer session "turn" [("text", .str marker)] + if session == "private" || session == "off" then + check (!(← f.answerDrafts session)) "unwritable turn stored an answer candidate" + let _ ← f.hook .cursor .stop session "turn" [("status", .str (if session == "aborted" then "aborted" else "completed"))] + if session == "completed" then f.waitFor marker + else check (((← IO.FS.readFile (f.root / "work.egg")).splitOn marker).length == 1) "incomplete/private final answer was saved" + let session := "late-origin" + let _ ← f.hook .claude .prompt session "turn" [("prompt", .str "Original task")] + let fields := [("tool_name", .str "shell"), ("tool_use_id", .str "late"), + ("tool_input", Json.mkObj [("command", .str "cat late.c")])] + let _ ← f.hook .claude .before session "turn" fields + let _ ← f.hook .claude .stop session "turn" [("last_assistant_message", .str "Original task is closed")] + let _ ← f.hook .claude .prompt session "new" [("prompt", .str "Different new task")] + let _ ← f.hook .claude .after session "turn" (fields ++ [("tool_response", .str "LATE_ORIGINAL_RECEIPT")]) + f.waitFor "LATE_ORIGINAL_RECEIPT" + let session := "parallel-tools" + let _ ← f.hook .gemini .prompt session "turn" [("prompt", .str "Parallel clock investigation")] + let fields := [("tool_name", .str "run_shell_command"), + ("tool_input", Json.mkObj [("command", .str "cat parallel.c")])] + let tasks ← ["one", "two"].mapM fun _ => IO.asTask (f.hook .gemini .before session "turn" fields) .dedicated + for task in tasks do let _ ← IO.ofExcept (← IO.wait task); pure () + let tasks ← ["PARALLEL_ONE", "PARALLEL_TWO"].mapM fun marker => + IO.asTask (f.hook .gemini .after session "turn" (fields ++ [("tool_response", .str marker)])) .dedicated + for task in tasks do let _ ← IO.ofExcept (← IO.wait task); pure () + f.waitFor "PARALLEL_ONE" + f.waitFor "PARALLEL_TWO" + let session := "ambiguous-tools" + let _ ← f.hook .gemini .prompt session "turn" [("prompt", .str "First origin")] + let _ ← f.hook .gemini .before session "turn" fields + let _ ← f.hook .gemini .prompt session "next" [("prompt", .str "Second origin")] + let _ ← f.hook .gemini .before session "next" fields + let _ ← f.hook .gemini .after session "next" (fields ++ [("tool_response", .str "AMBIGUOUS_RECEIPT")]) + let root := f.root / "data" / "adapters" / "unattributed" / sessionKey ⟨.gemini, session⟩ + let saved ← root.readDir + check (saved.size == 1) "ambiguous result was lost" + let some entry := saved[0]? | throw (IO.userError "missing ambiguous receipt") + let receipt ← IO.ofExcept (Json.parse (← IO.FS.readFile entry.path)) + check (!(receipt.getObjVal? "turn_id").isOk) "ambiguous result was assigned a parent" + check (((← IO.FS.readFile (f.root / "work.egg")).splitOn "AMBIGUOUS_RECEIPT").length == 1) + "ambiguous result promoted into the graph" + IO.println "Native lifecycle: aborted/private/off answers, late origin, parallel and ambiguous results passed" + +def Fixture.cleanup (f : Fixture) : IO Unit := do + let sessions := f.root / "data" / "sessions" + if ← sessions.isDir then + for entry in ← sessions.readDir do + let path := entry.path / "daemon.json" + if ← path.pathExists then + try + let endpoint ← IO.ofExcept (fromJson? (← IO.ofExcept (Json.parse (← IO.FS.readFile path))) : Except String Daemon.Endpoint) + let _ ← Daemon.exchange endpoint "shutdown" .null + catch _ => pure () + +def integrationTests (binary : System.FilePath) : IO Unit := do + let root ← IO.FS.createTempDir + let root ← IO.FS.realPath root + let f : Fixture := ⟨root, binary⟩ + IO.FS.writeFile (root / ".eggshell.toml") "semantic_matcher = false\ndefault = \"work\"\n[eggs]\nproject = \"work.egg\"\n[profiles.work]\nread = [\"project\"]\nwrite = \"project\"\n[profiles.private]\nread = [\"project\"]\n" + try + for host in [Host.claude, .gemini, .cursor, .opencode] do + let session := host.name ++ "-writer" + let _ ← f.hook host .start session + let _ ← f.hook host .prompt session "turn" [("prompt", .str "Inspect the clock")] + let fields := [("tool_name", .str "shell"), ("tool_input", Json.mkObj [("command", .str ("cat " ++ host.name ++ ".c"))])] ++ + if host == .gemini then [] else [("tool_use_id", .str "call")] + let _ ← f.hook host .before session "turn" fields + let marker := host.name ++ "_SAVED_PROGRESS" + let _ ← f.hook host .after session "turn" (fields ++ + [(if host == .cursor then "tool_output" else "tool_response", .str marker)]) + f.waitFor marker + let reader := host.name ++ "-reader" + let _ ← f.hook host .prompt reader "turn" [("prompt", .str "Inspect the clock")] + let reply ← f.hook host .before reader "turn" fields + let output := if host == .opencode then reply.getObjValD "output" else reply + check (output.getObjValD "permission" == .str "deny" || output.getObjValD "decision" == .str "deny" || + (output.getObjValD "hookSpecificOutput").getObjValD "permissionDecision" == .str "deny") "new chat did not reuse saved work" + if host == .opencode then + check ((← f.state host reader).getObjValD "lastHandoff" == .str "") "delivery acknowledged before JS insertion" + let _ ← f.hook host .compact reader + let _ ← f.run #["ack", host.name] (some reply) + check ((← f.state host reader).getObjValD "lastHandoff" == .str "") "obsolete receipt was accepted" + IO.println "Native engine integration: four harnesses save before Stop and reuse in a new chat" + moreLifecycleTests f + for host in [Host.claude, .gemini, .cursor, .opencode] do + let project := root / ("install-" ++ host.name) + IO.FS.createDirAll project + let config := project / Eggshell.Adapter.Install.configPath host + let initial := Json.mkObj [("otherSetting", .bool true), ("hooks", Json.mkObj [ + ("otherEvent", .arr #[Json.mkObj [("command", .str "keep")]])])] + if host != .opencode then + IO.FS.createDirAll config.parent.get! + IO.FS.writeFile config initial.compress + let args := #["install", host.name, "--project", project.toString, "--prefix", (root / "prefix's space").toString] + let _ ← f.run args + let first ← IO.FS.readFile config + let _ ← f.run args + check ((← IO.FS.readFile config) == first) "installer not idempotent" + -- Reproduce a process exit after writing the new receipt but before + -- replacing the old project config. Both owned versions must recover. + let support := root / "prefix's space" / "share" / "eggshell-adapters" + let key := Sha256.hex (host.name ++ "\n" ++ config.toString).toUTF8 + let receiptPath := support / "receipts" / (key ++ ".json") + let previous ← IO.ofExcept (Json.parse (← IO.FS.readFile receiptPath)) + let future := if host == .opencode then Json.mkObj [("contents", .str "interrupted version")] + else Json.mkObj [("entries", Eggshell.Adapter.Install.entries host "interrupted-command")] + IO.FS.writeFile receiptPath (future.setObjVal! "previous" previous).compress + let _ ← f.run args + check ((← IO.FS.readFile config) == first) "interrupted installation orphaned an owned hook" + let _ ← f.run (args.push "--uninstall") + if host == .opencode then check (!(← config.pathExists)) "OpenCode entry not removed" + else + let value ← IO.ofExcept (Json.parse (← IO.FS.readFile config)) + check (value.getObjValD "otherSetting" == .bool true && + (value.getObjValD "hooks").getObjValD "otherEvent" == (initial.getObjValD "hooks").getObjValD "otherEvent") + "unrelated settings changed" + IO.println "Native installer: idempotence, interrupted receipt recovery and ownership passed for all four harnesses" + finally + f.cleanup + IO.FS.removeDirAll root + +def main (args : List String) : IO UInt32 := do + pureTests + let binary := System.FilePath.mk (args.headD ".lake/build/bin/eggshell_bridge") + integrationTests (← IO.FS.realPath binary) + pure 0 diff --git a/adapters/native/lakefile.lean b/adapters/native/lakefile.lean index 7b2dafb..3cd0de7 100644 --- a/adapters/native/lakefile.lean +++ b/adapters/native/lakefile.lean @@ -6,6 +6,11 @@ package eggshellAdapters where require eggshell from "../.." +lean_lib Adapter + @[default_target] lean_exe eggshell_bridge where root := `Main + +lean_exe adapter_tests where + root := `Tests diff --git a/adapters/opencode.mjs b/adapters/opencode.mjs index 4d1fa31..2c13f1a 100644 --- a/adapters/opencode.mjs +++ b/adapters/opencode.mjs @@ -2,13 +2,12 @@ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; -const adapter = fileURLToPath(new URL('./eggshell_adapter.py', import.meta.url)); const bridge = fileURLToPath(new URL('./eggshell-bridge', import.meta.url)); const prefix = fileURLToPath(new URL('../../', import.meta.url)); function command(mode, payload, directory) { return new Promise((resolve, reject) => { - const child = spawn('python3', [adapter, mode, 'opencode', '--bridge', bridge], { + const child = spawn(bridge, [mode, 'opencode'], { cwd: directory, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, EGGSHELL_PREFIX: prefix }, }); diff --git a/docs/assets/brand/github-social-preview-1280x640.png b/docs/assets/brand/github-social-preview-1280x640.png index c6077d84f5253280ab8b5bdd55c00fba5f09425f..ae3018933248ee098a87cb532a8e9ea923650915 100644 GIT binary patch literal 102374 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

7t?2Ef0(8jts7(+pJ#xgQuVh&w)h59cXzApVu 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�yv%Gm|K{s ztPTs~9W5hI&{;q(fyH&N*`c~xY1DJkzS3>$X38@UPEIByD3j;rQU_`A@VY=C zg8d*$0`EecVXLwSgk}_t=|E^k3xqN%Ap7UUWj`8D-xbIB!Oo5HeZBy{r}rgALSmfT z>Ewv%9YVG13ZQ>tQ409HYWkg&oRs$@&%O5Tr^?EM@Pm2tzHkywz=~{(-O(n~|6%ug zCF{!E)EF#G=biTU$}dZlX>fVRCnhvB0g_ky>Z0=G1bRa~Vu^2heb>gBnZWcR zz@|W6FnUl4aCK~WctDt1%RlH2@A0!CLJK~t_5?#u#>q>^EXc#T)VevvT~)QNmT$(I zaDIsq7JhR41JV+fwq{Ev+doWL0lqwshbOQ9OkYEX&u`Sa>I}dc9wbM15i0_-wXa^y zRoE`>dysLqjkYF$A^n;`F5o*oI{8|^D~|jp-aeo0*zC1LbZL2MX>qAh$#}46uwv0b znqlAG{(k-R$tr_j1kFW>o4k&9ZBbfURFs3R?&f|_5cr?T*ITDsVb?_(5#0&*iA9>W zWD$>mxG$9<0}>p0SeRi_Z?DIzGm3MhBqb+iTg7x`8QFMw`Pt@w4Ww6{p0d%&+bq3P z6nQW(n#^15SPZCJAX-N&D+3Veh>{+GLiqF{=UJq%{BYOfxQdCTak`z z`kTSQUPqgzk6B8b9Kn1Z93Je0801^@KuSSYR`#!cK_}j`l-d>eghbH$+^fb5ASQ9K zs(^MCYiTjLioaYvr?j+u*`D$U$SSje2B9iHO<3;TecezfuOZ?tCnu*US(Ngwz`AFr z9SyOwzq=|XpNb<)j&{RfxN3Svw=d~g<$S7NA?nOmygP2n(4J)bi9Vm(mQ2-v?FSI; zBjwsA<8e%)dXphdi14q9pZI~~!&{EQC(vN{st9tScZdpIH$q8re(xd|-gF6bo@S4x zal5Fa+w!>R-ArWHyUYoyX1;kd__Sonda|B_k^0iNy$NtY?5b=W{F|2jE(i@iH(yFY z!5sTN_r0I7T>)IL+mwE`L)2UEkm*nbsWd=Xnj61x z>){Bb>8K0A8XesO)$}?2eI|~qSegztBIfBzpY^jI?WT~s#zpwjU7c`3ZofJ)*yNcQ zBIogaa-!Hi-~TNi%inIgo*p;!(#I#R^C0XspNIFal_&(V!de`2etfa_hEpue9dd>1y%qeUyC7XhRg44B- zZOe7bCTeQbLsAx}HTE^6#z1i_Xsk+ce&ONb5|NXGj=;w!q~P~RqUjiD5|<(2N?H3( z5Z2;_MTs7U26T*mEPY=_UVlCg{+`BO+mV*{6>R1*@!ZCp>#1@yxEUGdqr-qiPzB#g9sYrD zR8_}@ho|d^rpdR{pr`HIw^ASq5SMhhRrjk1&+MYYJS06(x2sIi&A`fr|Fc~K>V2#} z-Rgzawan3GWCNiJ+b9|fJ1cSMH2NI7`K!Y`OzWMnu&tY@eV?Y08cH_j=T`|X9??8u zHELa;XQr-rV~^NzwBsvz{$f{B5>Uek7Qfafo;(af*fFi|{C0K>lRq=@v(3zo7JUA$ z5SnXZWc_4pYUB4B;l-n`IgO4ld)gTup&BP${C9SfVi1TV|YhjY|vG0iWZ=N3~-1grNL4mf1z{!-O;L_vVIojPr4|@x}rD~n7 z3#<`OVx`VcPCJ->;-`KB221Ye&)-e19#k({6I$&~dt|R#IQo@!b}@XYlzMRsBbu?4 zy+T1qvDc}ox@k_MPV?@30dZq+Tc}Y<+pD(gcAGE_G>`uWWA_7r7oO3=kSKj~-v)>Co zgdVPy&v@t`J$ln(WG7!(&uoh+gk7JKVuJwc%(5&*B5$tKpD zF6$Tdr4kVk4b7t={CkWFwTs?b(jZvkWhmtfYZ|eBKA<+a@yi?s2VnpJzR!hoe;9?) z4kd^Y>0Hgdqf4C`%G)+4G%HJ+1-8aQ4}eF-_(i^Jt@5S8j!bkv$fsyob- zOCly9RaRAX*_)nN3?t;qql%LHgNFw*tbZCnj3VS-S*6?H!p6<9h{0Y)?^@28lk*s$ zjPw>3<)fpB=g}q_CMXDZFVFHlsy@7w8~!N;1=e%a2vB;?)2jzW-0I79gnvo;{F1(J z>{J|$q~)kpBBA!6f^rvfrN?psTt44w=tjoc3|L4m@wM>ruE`KO$w{P49ZQQ_IPTP0 zwY9ZhOG^g=8WGrdL|6!)iwjaYf+NRc*klk8rMET@E;gYyuu7bJ7$GAxG&MR+4UCF3 zwwRcXUj>@pX}J8;_1fmqqbGUU*#WJ#>2WnRI-#Jb;A<#^5jnQ9*bagv*XIrVfMOn< zG&?$2b@l+X1!8=1oV}Z5adipj9(i)gK#nT$hPAbcKJ@>&;Sa>djU*hVkQa;S=w)Q4^@G3?4RGXeps6}HnEjb7h1kbmT#>Q8_V;IUJr7j+yW`VJjH#8U3)ol zjTS5}3TCQ8-pUq7I7|E|g1C+ww)6S%)g{%*KxWU>+!e||YpWP#eS<3?vB?E|*q_pi zDyS2Y?Va?_pn`m82GCDT7oFdJu(j!4!J&SQ#qYjS`QZcdk57tC`T0-I4-dx%2T}Z+ zP+k4bPPw3Nv>IK$YX!N|6ZgK^8+7kBY)({3U3jH*GA4#Hw`VK30OMJh+E9|7t`iJN zy#NI?L~Cor!uO1&ojA9FEEM#Z#sk;mTG9)uClA8W{n?6(f!7W-UqEhYO(#d|D)^SgBRPPWeW zj)D;1^Fsbh3y;%Ngu&o}ISOKtU{UtwkJFnT2m?#&>GrKj9t0I`s0Rn%1e1tQo%&uA z5k5Y<3ySWh7<|sx=!5X^52dAl{``q91{nVyAi6T4kwZ>aXDkcBr~Ya>Oc&wCx$CW* zf`Wm8LA^5TD(j%s#)!qDW$TBEmYQ;@k4fFH~S*vVD^?XCWRwF}I84T<}}kw?g_n(2P-_ulW_cv}#>tH69n6*e%^~Lqg zBR$s>5`wSvnz7q!3ukqte;i5mk>0#?-Qd3#wV&!naiO@5{q|T&DgrB%j=1OY{n+mM z=TLb<1b#d^g39+W#*}5TVF(xH_x8Nksg{9;$uj~(h^QE{e|n&g@;)uLa2?XEA6Zrk zhW08dGL~9r@(owgN z+8!t|6~|l5cZqgtHMqtGS50_PdJn3emVQY$Zn)Z7W=!;5jV?)l$6?+Y4oeP^4Z~RB z>3MzhpxAHG3uSHXF?%&jQPqbK-BcKsTbh3_D^JUeq|Nv`J~gQ{pX$R`r{#x|WBmT@ zjYNmbrHDJ~On4{*eGQS{YHHHz13qSgLTvcEFdHkxT?B-7VM{%!%u!!WO=xK8L|a;+ z6P&53`E}X)HnYH0^LYRF!;mjOReWuz2aHWsAC{$kX|_3GLa-3ur46Kyk|LR_EB19W zU2Jvp8$hhxLMN$j@hbDG1qHUQcMT}QY1u`)?*jt6WqX`~4(l<7^K_u6aBfPCs8BB> z`Xi%mWq)O(mi`k_Q3Ua@-H)u_vIV>?RFfsOPGR_ZG=+L~-oc@Y)H6#|`zyc8#SJ2~eZQI4p_VU*1WUF1|~+}xV1ZAM$@ zO%C4S=OrdW0wU#8UsKcNCB*YY;j-T6*g=sY%lTezsiHw3ar!YX&dSnKOH-r#3XMlg zOA?`?rp+CPT&Sj|im^5}*FAjTE5U+5xl6+5w8kKKge*iIpwHH97|pj6Gf^ zGrz11B8HOz<304fij9-p;+!>2_sZQ$hkU`$A3vJ;8X|Ext6?7AdwNgihEJf_@k2CW zC_Q}5E7dUL(5jj#D`V&0SgJP6ZluaEjhOyXsvdG8;Bllvg`bm?V?DhSpd&0|s5@nA zyf56;glwmS>fbR>S6Rbe99{J>Q_a>_AhOTu8LQ4Cd^J^dwtL}nTEE1>E%98TA`B%> zsRL~+IVuH}6@QNxa9|M%!{cE8~gcT{9*U4oVh#*msVUF>q?0jShz*Zzm zHppigb<|EpO|hb-mtMj`sHC{)Py-07NGw3)2YA7IjeXr*lSor6Iz5jU`3BV ziab7b=}Oi2K9&`cIap79Q{DRI&z}#aDpeKWy=ExUhx8N`mD~2DUHJGQsGdB21x|L2 z`2HOpsvu9kyZ!^(&7-Z8LrpycMK;RqlWpVUD}92#)i~}~KPQZtm~5_alagb+PCLsQ zT(^$4DOs$x{$zBG{f(dYZ25=1*3}9<%`LQ#S-IFSYMsw6;?wb3BP~8PmX#-4RHWyS-Y{~+)s=`|s0t$jD+8)P`s=KXYHA>?^^x;T zo(c1-5+T=_{BX0kG4N+?7<))4wj0y25j%(0+O`zTSn`arjM*xlXFqR-mip!dD?H-! z{zEJo{c&jt*v}|J3QqK(QEGBrM~Qo}(_#Eln(!r~tgH>AEd0&#tnP#(MPyub0y4sk zHZx)y6c=S^Zy-)C;v}QbPLB;sG%mERg1h2O5~DsP;ol>;i8#pl9swmBDj{shGJ)UM z{t;CWy`!VUkoA+JG$F^r@@CJX{i*ltZ{xO=zO3qMv*W8eM?=OD^`w|Y2442+&sp}{ zX@SF>w+j;|KNlA}F8g@}2}fmAo&KC~O7z4ZY>=m93enc?67*Y*O(abH_@e8FGXtIpoHVN9#-6 z2QFL}Djz?3l}(aZr=2r6-fJSI5IL~&Xja~d<5ZuAsMO=_PkgmxBI~gsIm+V zvXO0UPLKiKa5u(0ZIOUL?A9%T9;RO+rf=o?x-Tq-wnqs+mSoDRD=Q~*+noKLZ?m1N ziTjwcy7Z^pu!zmE7*YZ>N}&yfcKhp#35ki3cZppuM|_rJt>d$?i;>yX%^}CoK z6R5uepfLk41E+wV^otl06R-8HWN5VNd*zcLXw1f>d*`bfMQ>N%+)1pr9v4ER+&(HS zCMG(1?@N`F+xDCr*NKp)>&M)5yqJ#e?#}SH5f3FD$NST4WJO15XH)ci{uoh)ytchI z2L*_b?4lxtN8#@xIy$)X2yP63Md&?6oKsgjgL~ zT$g6K$?qB~wXBM#m&U3tF7`X=Ac=T$rglbkNNr9Iz^#C!`l8NY!6kiXpMgl;X0B+x zKWg5EoF}os1B8tE>{rG?n{;;ko+4d5i>Hi;%GYTXrB_sHjJgg$af4cy0EX8Q`^G)h zg6y=X=B?dg=IXAu^_dGlPgNj(Of{8|D{}!!dbF87bq%0J1(lF;SVh z?H!q_qT;=aXEZ!aAJH)(Ke*JF0Er<%P!cB~#dALYm>nt^hQxw6INsLQ)b!w3@$|V` zCIXqJkBl1Q7cVinh=`<}!XOzBaQ5nT$LFc_Z)JjGmU}_k489bCp@mTTYm8KOU+=KZ zcvizNkj0N(AN%uXe*VvVwlGr&o8iW@oWf#JASIBF**)_*HByq25^=^y5Z@z~i+}c% zj*IR-eP~ZM)-r@AYD{|N$2)0*#e3j#A|d_;Cr1fSJuIa>CkyJX6$kd#QGOUX_z;yo ze}wYVgd*ZZR6tbTEwYS3ot)d_2^%QCwCI>(CbF3|zfo)wFRek!FMYY?O>UFCUSS&~ z8WbWLq#O(;_KsO!6b7??OlZ^qs?`|zGtXySg4fmE^Nd*PV=!?}Q_+S~b5nG*OqhsU zN859fCFwZAGiZw1y2B$j&sPW-)EfZYT87E@8KR-AsHW~7nK)+Mmj?Z>V;|3_&`no) zT|-oy*7tLD`{5fg(T20LJ!$6pcb%P`wHH6(A`<7zNGHxQ#CUC15N)~;2^{T$!H=RHArf4uEw`guxPfl`7gD$#W5Rn8npb-ZpH8Xb-9>1D)6 z$5W0D3zJ4XmsTF?9nCb{+A`a*It`VSgb@Dj&CG)GcgYd5yh|SFK|etV>~A{az1P}B zRic}1BpNZN&!9=QoQ00=6%l^SJ2mgksj*e%o`x`KvGV& zhxv&C3-M%v)@IF`E5M6Ds*WHwwo|I}?Cgxt%q$s4JDMgwB|Zu&mAL35DkIi;Hl<}G z`p+xxi9T%6_qk4n3GSQoK0eFkqYA3#{d?bkyK%z+jOnT}T|P8UmYN*!gkE%%-Ul9l zmo!7opG%i}HGvB=B>E)TPQ~Nb{>J|2y=Q9+NLz&Gx zJ~xqnFXi)MQV8g&sy>XB;w|);NdS$_cQ%R8F1|n8e0R^e!TYy~s_Muk>ixuuEeQ#k zty-sJ{%Q>J6#ai%KF(*^4Px* zjY`vaBH1J1b#n4lG8&Yd)fCmX);PboR^t%ukFNAZC|5 zb`O~!kem8`R|TvAswx?%ChSpfgENX0T^GqxHfrVq9qBLh7>c5Y(BPnSrv|gwm@%w5{pIEL znGp<&L@=w+eQt=E`awG7YrZsn$oEU=<i*3=LYHj1|H z$R1iA8J;TVVHf`UyfxSFsKhQAc^gSR+ODP42c|5C!3(`Qt4(x%V3 zIayah&bSeL7XxEs)0#6+KQN8?->3e)F?B!ZRC~^xMcPeQ4~>#fSdYe~+UZog`-auk zL+@dLq47B{i1_*nc_mJO3dx%HQ3q;?nM{lKxBrHah5C1fEuf#wcER-{M*j4CUf>2? zp0)vvySzu;3mib8mLQIx%be$jH4H`Y@SLiJ6BOeKx7z z{5PfFo+=m)Z8zud6*HH8oIng&$|WEqWYnpCOiNo(hp|C`Pe4E?Pr_xr@=NH(InV)} zT|j>5?`S-o{%*qd1}`k%>v_8eQCYHHnwnbO@%%np8}||tdV(!oTwV>C_t~AVrvh_h z%*rgX#en7P@Q{+<(w)*zpHBR|z!vkrM+xg9pe(w2QdUN3bFp_3++{NvM)gMY&eFwp zmpnI_E87SlCXXojJb(2MKvEGNg$1h68ynWTNo0Tr_NnEYp4O?Z7X7zQE)VbeD=xp4 zm(VIXx@`Vhj^Q;rFfe=3*4G41x&G$LuCl)OVe)f-Cm(&l{+ab9mAemklUhmB#!QU# z2>thr6`KGgmbE8vAvD-cdV+34dBHBPqx%oahRe_C=`$)fqiGfMu^+aOz)I$~N{EU|3#d;iDvFBD2!{4f>Rjmm9#ugCiLXF7-i7s7 zhS!bhHZub=Glzxw4cgk8oT>pd9Y3tNe9ugkc|R(w-GeH#ZgMgd(3blWGjhIs$4*JbQ-nRf3GF-;noV|#2!z#Ln)@Xrr-9^9wu-; z9_Qc2j@dcq^3(+ihi>u34Ch1d6hXI9qEw%(f`VC<;M@Qq!K({#;Mj_@f48!)BEvH_ z*3~5k_#Vu5S|BPW$#`KdY4Cq%M4|9C5{oCJdV!w)`K&nfv^=cI)2Kh0y7n0zu7P`d z&i7h+rDW&b%Nf#)%p55eVz_yLppWVwKlJTawaCBiT&D{AXH%tVo}AZpZHm&@<`PBM z$EU7P;M#rdLuf=j13o&J?@@#Dx4syf35T8yk+U;TGR~UFhQf-95a47q-!`uK|D%z% zWPk{tOa}n&WcN)Qt;n&?!9U|sb(yrS@EmM3^@f_?TK)O&EcBQA2L{yClpGx#Ncge@ zrK31()eMZjB_bWA7n`HAD#+^u=e>uXCvO?Vr zYnx{}y`PaTp&Vyu@-izuy~lv1x*Q~d{yUJp{zxpA`K}@jK%bgDn2L-ja3v+A;C8zz zhGxW=R{Gk{czM@eTEX=VxW05d%7)C*2V(#J_K*gAkb-CZMMW3F*CiBx|H3#CPxUA@+f={C5V1r(hMd7Tb$r zgt+V~ii_VC?+v`T_Vlde@P_Q9S!1*tz_NKuT_TKG;~^pA;QYO$tn41^OT<#)zv<74 zgNE$K* z-=BNI=`Z^=F#pYJ)Q2icLQ6Tm#ptqTz0lX^lC*v#dte*720{nSS?!8WaFE&Ffr{wg z*I$D;NvtQ-E9JY%1WCa2@q|tpTBo*hLAYR|835>$>a)AKTC!|qwa$}c2nqLeaSU~J zUZzTvqK%k$?yGd0{%h)5QDkV0WF{Ka&t_7#uYl@bGk-|mDsaARW`!o$M!zc2jtvyV8Q ztBRVAc4q@Uc64Y2=!%W1VgKG+xG*#;pJ{E4^K*rqkWgA&>V?W|W^IM-AswaN`@fG! z<#u!M@DNSxmBwyv|BVEER;U&ZeUnvERI?r0`nPlL58vHD65FG}?Wo2e)zVX#b4V+xsqm?#{pY3DUy4VL-Fp$H+_Zn$K2N7VbDKK@B?f@w1h4r2U}nrcQ1nZH+aiq2U-7~y$mr;*ViUpzyIRa(fayW zTOa6()CH2h{ofkcggE&RNW*~v9wRqDbxCRI6E3d80iJ?_UdS*+Yk!9O*KD*l148`2 z_X`KV!OG{~O|QRe+{h!YbQoXp?;j$Yd$*z zV6W1)-l7zX>Ci?03D*x&NF0$A!Bxu1Yd1tJy%EbIYNYr(uQRxk(W+mx!hr<8NgEAA zDWh>kp&m~BZ#}Zk?+4`Jj!AtTEU-lr&hmqWEzbxzX? z(f#53&0jyz{XG4Fc1TE!pazR)c7lua&Kop7siIVL7;v0j7_f!ykpI0LUYg32_iw7J z31HGP!Gm?~N-NXbFH0{Q-M%>-D@UOu9x3foeL zJTp)%YguV!?H2Ur5@w{5RC=`f2lHNl#L0q(jFPJuM=G4eg4daeWyxU~7(&<)qj3@$ z1KE zQ%>@=)v;0OUn3*rmA?0%@zj3L`|x<;c;eX1^R*2>=9zsPRCXu^CyPMesxBi0;$;nMk~yrmv6L_(*=!b@EmG`|0AoR%uDt*u_GF!oAqZ5w6W;hp5tS~5$oJ%5BW_52v0 zfs~#rMyj}tv=DlK1jH&RN>l_+pWf=u+znD_Yr5YLr%X%zM&R0-{qReomC1!ZbxQt| z)18#pfNT1?U{+UgIoAEtd*Wwt&f7>g+vqS~dXJ12%IiyWwSlg)5%1a8n+=^}4T$%u zAFdnU3E~#BF1QN1U8^~WdiW8^t1~2)>xoL)s%?k+B2ls5KiW(88gBUV7It*6_Wk>8 zo0>*R3nKvp@SHL_ERV8_0?QHYn@+vd0J+10-BUK&*wh9H*E+r#umE*;Zd2XQ&~-T~ zUh5=ZjvQj6SNo$Hn(Ag-lDSVs>b-X~vn>Btk(;(}9=uLr$Lw zYenA7iisfxV)e9Yz5GgKG5M{Gq(oO&5i4ty#8<8+3guYAgfjP1KR-nKqjv56Wh~?< z7Jfl4dfuVN?pHbXCrbo+jD9+)LQ7|x@nzb2%A$7XJk?VR2&q*hP$@c(ou)I z#2pmX#AspJYA2!iyW}W-dYC?2%)+u)VskQ08vSN-)%^v%dSjC=YdWZ2_2|PYYGv`&F-|_W& zgZR9zGZj=)sm8_{TPDXpOcF{qn{Um2&XrveREcj&EAPsA{7RToaSBR^t?#3pZST`d zzHSx^Y8G3>CmOyTv<9VkG1|Zx?@@<~ zKG)Y_8YHh@{vfyNoZ$r(uE4vnOnpaJw-B%z8b!xivT>#g`k3IU>@km$@z5&$P$cWBv1qqTeft zCjE*~hz$H|)yq0Ztb`E2GL27(vam3m9qCyWt+t24w*3T|r*?c-tdDg3{$2)dqO6Tb z22FpkT;VNFOtX*92(tKS7Q5zWJm2x(1i1*`KW-6v>@hNY>s&P0N4+nU=8p%`eCY_s zr04ArJH2{RoX6UGGy+SK-gglfMN8E3+NcQZx1!qel=WBaK{WcVaLwUi1z!4ExrkmEDlwxaC5~e3lDBDrqs0KvZqh?i`+8Ho1*`$R=|>(W0H&7EE5v> z)Lf?y*FKpj52kBLt@1MdoH%yQMwpYE)xYTWAY~TQPs+w?f>+nqecPb_VNMx`Y@g=k z=D40^5Vy_th!euV-012!L|+k%Jw>JO?s$Ig@F!O(4_Oy8q1mSKH%&rug2@%#O|5C! z%KcwyPKVO`0*!xW$H~y{hnQNey0P8aZxGy(_w+6JKbp=uF3RtD`wOU`A|Nf@NH<6$ zNSAbXw{$I`(ka~_-5@Pp(%qfXolC=W{C=JvFaFYH-(sd z`?x*^#t>=w=KG!Wny6ha=^0#${c$@Q#}6aue<8jkNYNAnMRe!VAxk9(noZ-&B4lxf z_o1IhzN6lNxNfrhE@_)d8x9iusnX_7P-iK1SroQ#pR#RgB5gO|a`f_3M=wrA8G4G5 zjiH#084k{<^UktDNL>5CMyIlJURjo5PBtRMuwnzmDfb#^@F$P6;mg?zOppt9j4i%` zL_4w`f8v|^teS?!oR?jW3K1fU^R6&k06RX&o9FUu@6FiKMCa~A)n7wS-&tI^D9Ys> z=)O$ay?{9;Sw2Qw@@IjNvtRo_f%7m<^eNEi@tE_g#OzL^{CXCucI&LU)q=suCR<>c zUUay(36@%{Pm8CPjHW|q&&~F_B+o4+()wR+>3ag5!h|`|=lgXJWZ-#@#QeN3S6nPV zc!}g6E&T}hwqUWKNs%XvXW;g_>TYQ}mwjl-*)s8Iu&O#>XujB4&i?%#qRwxh*P2ZS zIr!j8-&&n>GjO2N;Bx&EWJLP^i&oYHwP}X-4>J>N+l*y;b+358uHafEF#2v-Z0}fG z5GJI*g3P1~Jo9ZCKB^I3yIEZj-84~7t0(WKVcmZc9Aff(Csl72Ni)KEjE?D zs1X%X5++kH8-`cwwGQL3{KVchFg!LqnN?kM=Dop2tGoVsuSM|J{)&8y2`jF51S9Xr z%#2K5A9^U|B<=4$jq(?58cYbQelkgr**+|YMD+dX=)j+OYtd;ERDi0z+(?z-QE?VF zo&VYvN=Gec=OzjktLgh#sLD?(Wu-pUzn4{C!#OU-y$;cyCQuLRzVdsXH_@Y~| z4RM&nm2@-(R=h7_m zdBeH4ec)~^a>R!u%5*QT{aq|Q_@gLksCqk=@^x}MQA^v2_h2_s0HMHAa_$|3xp&EP zZ*{E`dRKD)uz)8Z$iX!5St~!TuC8Z)UQht!ub_Ye8y^W^tN>Kkg>;2_6LfQaf*Qrb zveuoM)-n)fZ`+Fc#YbqB+ojsmp+v6~wWRn#0r~&+00`KSTy)Nb=!KP$buHKvAZ~cX_)(a4 zIiRLLT z^Szi5vS^H*g2d9^TdU4(5SJ$hq$7`PxZQ*11&E=ZuwP@A{Y;3$N`DMr26hq6*!}ok zNSQEH>oxYUJQ~ds16dvLshfa1*?@zqJa@R#;g`Rn;7!zesNF-jr4ez`9H;iN!wwOJ z{Ls@xzou~OZ!-u_-#k6vA>57_W7u2R_hAy+dKKG!L=?j`)++K zrocuI5htF^YNktRa==W|-U)KQHYh6KGAsZae)iG)eX)0yTMcWV)v~_A{&I=c(=~}7 z&r2XpAZ2c0;v~@PHfY+`41%LOFGLisn%5pFU{_?jvW5D5nJE{)(tTAg-6L8lY)Bs1 z+zOq&8>k9>1rd6WJyGSj`GX=!C6O9!8j%P+SlNZ5R6CF~OH2V@0LJKK05!#f1W~MCd?SD`PjRtNaX@^}_GtH(skV*B zS_QyzSH3Mq99z41O9dkq2ICaWnaBFPF}G`2@KHaG$m4Q*m1iq$|0YXT!8hIC@-*%j zSk)fSpG$=ljBUk6nlZ&68o5?grtmTQdb>YZNMs{Q_7C4bwlR9Fo`S^`ACX3;wf1pv zAT*7~Jki>Z+hmpb)^YpwUX~&TKG90octC_V+|S6El!Hsv(|y54&Gvzcp)m`|tL%;) zH@C1S-xW0<=DM?wY>*>59 z9{@!tQ?K0_sw=A7ZR7aiR&$T)YAfZ0$hGG*G$M60AVxRB%ktIQs=-;U^Yf_zkj?Z ziTXosU|{R@UhM2y_Gs&)9b)t6_`>vdkj?aa5md6})?43%PASafY`>s3HM?*;{lVK3 zSPFe)AauTr?!P6LI1O!EP?paq`#iJ_k|MiWvVr!+@uB(Q&hF)kcta z+je1@T-1J5CCAWvQ(e7l6RfPMt+4#%?N$g5O(Psh{_hiavE<^oaO>xDScy+@lCeu@ ztrgGRreZh5ecAA|lBTEsEG|!yvG9Ipd>iBkE(-Vs8O*q{0^!kw>)KB68w9t>uo<^X za9ruR_E*RcEgGPu9_MOYJ2o_AMO!gJc)T;(uTFZ+3RYubz%n}c$aNh=T$haHwXly) z*M*HAl`nv*dyz>FX6>%XGRB+*l52uOfJ6$?+^|e0@tEbfxtY8#^qQdD5^;UT zUo>Bd4llK;DC=%*?+BMFSKnM1@Z6fvRVEJJzTZ*3jyZ3yOA%CNGuE2o^T??~Px))D zK>T+zAx`vDfz(W}+Tth_jvfxqoHY>Ic{KoY;OFe_*B*Du07V@0w7 zMoUVi>;B;6ekoaUNr$_nYzOzm{b!}koeq%_?_Uk7mUecM1nDi$F?ah2Wk(A4TNQ!i zSo3jVCW$E?N32xX@LAYrR6l-DdMeA)V^inn_zS#VX*Sc-^bn)F0>`I#>`sH#G#J#f z1lwDCVM!G{1RtFSh5j_Y%~Xtj20_ISydm#-E!xo=`-k_fyh$L}#BHP7JZ=i5soCgK zy|rk3T2ihxp{o_I2btS#D4%=)56?d-!Np!Qsk!jEP6ko!E66S0{1B>S>DGC~VpBg! z(B|JfI3ascZo-iAWOm2-)wg67=I4DPT(4TyYtNqO`2!Wr+2jO7G(Ex_-Eu zAMLNA$3-J4w-gUvKEFMFTwXflwOuDIsl&V+re!)jU? zut&FBY4XlupMxGu!8BFM(@7Q6;;H_4=-t>G03k0cMlFBKL#4)Ms87oZwGj2ne{pBSVm1M(Y{|Lo{eaSWQkyt zXTozbaD*Qxl;^(qcFS8|n#jyg&%*egeWUN(+(WZGC2pyw3I|Rod~gI|?5A0HyPL-6 zOR`ipF6OLh8F=4~)C0IOU8l6kMFNk;E^CoMJMFEhGx2L!Ha3Rbpj>`57O$rI2rer+ zWHzMa%8c=+10hx%7;aWEFRa;K3yt3!cv+`ux%c-D#db*7J7W04e7PX@&&4-w$S@U)s7egV=#Z7B@sE`dMcs>#xg!eICy#b%l=2&i#`%0kja4G3U&0D zbZu<~)12&WYXe!)#>#esw9h}{Gi+=w9JeRl!W3rmZQPhY?-cYgnbOe4KUC#WQ8IX$VR{!LVR; zZUY{z(@h82%$F5WG?#_JLl?+mH<$UfrO2{1lkELPw2)OXbELTKxxS7NkD2zx=>9S& z%Lb8(VL>T5ujh2v_?ElagV8swxAGhCt8A`zvCWBGyrV-4?Solf3Daa;bXp$6E*nx;+5zsQk9UU7{W2*v(CxaKw|>TR!Mo3mrRgSyTl%)%?aQ3CkGPtT_>6xx)!@w$ zhKGoYLPzqqCmXvK!e$jX`yCH9+EX)YGK0^=*t`p0_d zyg1SMZ%nRK5{2Cr_!7)sp9X+2ko-)8I}`#M8Vh6R)I79jEDmnI<>fPra}zA2s)73j z$3Kr{Ggby0U%iqUd9mv<^d@|V85P1wD0n>-&EWyY9MGaLea?nC8mN|$S(Yv6$3}A!oOm3M^Xi&09!NweuqUht*^q?wFtS&sJ0|D; zHzrGdQ7bJamn2}7Y0c-d>#*AH)%aUmW{ZXijLgOrFJtsmTmpCDqsWz~^0=1{fXQ@s zwLj6Sm!U$JK*$5Nz8hWwgOLnJCZGq*Y8xBOcwk#)!3@w#rQj#u;XY$=y-sW$Cd$^O zGBq1~I%UHkY}U;5O`m@SDU#}E3z#lXD9*~^D?4rj1%e`z5BJ$hyA@873{_1Iv&qj! z*KF=U8k%~Iy3UNE_cSB;0`ZL1tgICrEb_yBn38|+YajNuclt3V$EC5~oF5HX>lEb# zfC{$KUdiV}bxKGSQTHdm?w;hG-IhIOxV906uZCRX6w*In978L=!{rxouNQc**Rmi_%j-tTuRGvnnxy1#6OXM@NP@--!8gkXoC5`W<$ zi=B^Uy)8%FzEh_} zgEknCJ$v{m=EXB)hpRk2DT?TS2O2vGps+RVvwX9eV37(cax&g}_VQ|V&fBS}QEe_8 zTQ;*r=hN6&3Q&q>XXVJ?pg%i3J?NTTwaHY+B)}kw!ju=y&+~XO&@JbkW<>ymJHrn4 z?$GT|!WHisn5fHhf+D=Z&e)<+8F0SWAiL}$SNUKB(%*|9doR$HKK2)c`Nr_(MM=Tb zW(m%mlCAS-2UZ}5*?H!x#F#b>JCCI@=%Jj)OXPxGnv#h%!=kXrr^<~Z;o)rD&Dj~8 z1yqieHy3t^S83^`_9B~t^1C5$fSy^eCflViGaH$l~*b@P&KnMnqb)H(!_7Bd}b?%6UneI81*J@!EAbTEM?mJz}$dAZI~p8 zG`)qF6KZQ=PJb1jTp3a-6Jb@at2dW$#0jfB51|2?71ME3tIHmNjq{BLf|T_zP$s-J zA9XvM=gqV;zQ!QFV0UH*BK_gMpk4L%gxsGWK;#x&evqhfc6A%~g^;HuSQI}sLzD4@ zh7Brw^d8ngVa%>Fwc(<$<&Xeo#vaCNLI!6DW9w}97O(O~tlR{@;yE9$-^>JzgjYN3 zozpiX#01+AGfQ8fhwzX4So}GSX zY%Br=)k1w)iksVg!i=G}e>jRxugdZ!A;V|!s56VRFb3&neVQ?EUa}yyLBsz>r3nuu zv^2kP)7oMJ`awZeXV@{jU<(cnJLy5 zOOlK@oAlh5Vi98;tUtN=@v177SXjW~li!-K@TM(IoGglOL`HsSXnkr5oS=Za-FDiA z{&^RQV3az29|H|$kSOG5VOCx-si0NE?<_u6%FN$cO>*k_WjU55`NsxEOf$z1w>EC+ zammRkL**Fn9Y2`f29!j<( zG4nodqmfQ-g7edz(g3(G`_77B?i92rYyEP|weFi`A%9w`d2o&hp7*2Zb08(=F=V0e zL$iD?9FsfQfBzO7JUQV~Bco6V=$jJPbF$wNLz45=}>>BHfg0=52UOZY(>%@xwuV6|bp*;v`B4@^jU|tpU(=!cCtC@A5=8 zS+&OX5H{SnZ=O#GPs?;-+oqi*@9tDVz{k^=(B$tmpqw6R=UC#|tTrx_=qFaAf) z&eOq?iMFx^KIDW=(zOR|Jp7w}s9~t#H$zv~^2YtLiiYm_rB}3u**}A3gZ;)SC1xNZ zgUCu(4_}tv1|}KcGQ%!!p6*GummQDT5<<@wmEf1JP@X|fe7*@&&_o#=(m}jmjUpgJ zAmd-^7rD<90s?e8tANM+!f(HqN~VBG@4eFIZTbveG57tCpc6e@$WOu_FF#?0kioUD znt{JPSpW{Z5{@S)r;S%xqOa)ac3YorW?X|2SCN8#%Bg-Mz=e#8QIJW7XMOTJ!12v^ zf&BF`9k-^&-@l8?c3BLGk}{vq{T7~yn7wVLzX;*05H8LKO*J)f9ofSaf6&tl>`-Fz znU<+@N&L!Insl_azoDJ&Y%F&*Rs{sy`klgot}-d95Hc)o0+jCxceg_e>HH0r=H}4w zS-eExcdXmh-A(RyB+B1AeB+De;81TJ0%>o_!Viod15xaZVfV$+HI@axI^Q)>nl2 zeCO`@YQC`kRGiN-t~XD3cq(W-lJzx-4`zV2-){*3>ajiZCS~`6_I_Yf8mCOVrJxz!s8aoSnQ|%IW~^cikI` zwiRWB825wgT_fsYR@|`Z>C3|f&&^6vK;Anf!b%v>xf>wPG?;k?|Mj2-aNSi))%gUz z42Gw;#+PdMZ2OEMR5=e2D^Z2p8R-+Cm`%hVZhvF4uP?ox0)Tj<-K=`0d>QE3!OcN^ zsHnphsNJpSE*cj}Rn%q%lUgt4&J#*Y3&0HCm*tYam~WVvnllSDlyJ)}Hh%#e?)cN9 zmCAQh_6hPp7oXfKni^*?l99n}e`X!(UXBFF=!14A={gITSeq-pZ_!sX-ifyvIp7=k z=tF$z!6K7V@a4#WyXO7~e}6bRHG?Rd`W7c+HeM_M6~FQ-qg5;AlVntMa*O?WZT^U6 z+SnRB!yxcA69pg%;HZ1qxx;JovZScUV=Gm(@}e?Cl#K&eR+%pwmE;vAN8wTHrA$q6 z!2Qk7$;lf~BP88esJjZzM%27KjERN}$m#%oZ|uurReGx0{rkSy*lG<&oNcecghqc| z{zjXX+P*MTT0Lh8UmpkD0FD!UZm2pQ9)#pgoP;WsQt&hNj}YGs%=;4`^0!e6-vh!d zjbNc@4Om1hMlkqY@~}EkJ#9KO}s+d z(RTu=eg)PUC0&t1Z0{dCo*6>OiQY%5=t&x>B23X^e z=3pnajeHisR8+?84+m;@{35pfvl0^CQs*2j; zbYt1L(FPVPSPJx76%j+W)}G4NE`KNAIg%p2aL7iJ+{2`F4ndET5M!=Eh4`WgKckHb zdm4Z+^+F&&AU@7+u!SOx5(Y~nlrXba12x-fBMCCG>$^U+(+qyZh>Q;tWfjnmEp2kC zd<%?oOh?m)y)et7@OetaeYn;+7LSm?K0h_(Z5&mg^@qG?4HLW@ddR7o%-59@Au zA*+Jlg@laBB$G;himIsQz8Btm4QXpXcq%V0Yw?~7ab_@W#e5~Ir#CX&J32f2o|~Iv zTf{b$p@$DzX?&ARj_5k@b_!4-S}b9WZflDwbl3w5Ed1pAlS!*Ck>chZ*S1^ViMZm z3Mif8le4`BRT`AJn`-4tP4B$(fBjNdTmn%o+lUB=uMn@UdLok9 zJd-0}IPQ2UOK!0Ht}Dqt7Ss%h!WM89YKjwTXeqSq?Iqi;X~5W9v>a%vCch74Ocm@I z6)Lyjj_eWid=!?lylpN(RODf!4nv)7vB@UzB*DVO23}S{{KjHzOZ8rkkw;mQnnvXL z)g=di`l{M-gi~B6BZ;~4YFMg+gh-GHR`)l|1x*yu{LgN-c`nXsHYTf6>d11M{JFst z?Vq1Q+^VXfArTst(9&7S)ep`bH45Z`?!W?lq>f8iLR8dWhU%&LQeKAS{rmR~?}$!M z#$qO`kt>|%4!Sw^y(8;=8X5M55q!zf(ENq6NYK9#kpS27)$JEAPt-77HUnY{!(P0& zy>qd%TW-(&DWvlGCy2ZydUXzK_ZoDnPk0ZjZv)9qtJwlOlUns}_HkG`Z0t;`(1fzS;hWKWf`4m%booJ?mTosX!o$=3 zXucx%d#?X%_CNyTC!}06V+O7Iqou~2E$ZI12Qt4h-A2mV#V}Kk%QN?iE2)oPEp5dX zF1E{!#BL%}K4kBfsl{V&Ws4!lkR#J*Yp1##FYfK=&)kn5XiT69|3&DLNoGyyGYl@( zRaN~LXll~hpaMP(}nY z=i3bGF1H(*s?Yx;diYW>BVJq4(9q~KA8|<%MuY35#*|@a@0K4B&>ft8XQ$IxkeNve z^SccQ0NjNEB4E!a;I`qHll}e0b^3=|J!`R<*v6FoxKT&WX;rdKKov;gGP~`$hZnOc zJs@3s^*U6fvrLj_z9v+y_XZ=nQjgS=H$|E>9|SJ?akCFB@4wvDhT{g*{LSh?B5lq5 zuGyppLY+LZ*l-%#639Ll!#K_LTZi*w}D$nDuI-VEj_(Y>^#{EQC!aIZYXcf(i5&;{fGywkrhLaXs z-cjwPDg0kA!1UPC)MD6lmG}%@=JwWbQVT3W@D}wB`ee9q(jnI<=n zr_HJzr5@B;JJ|lg0WO|{)c9RgUW3c&D*;#SaT|I%IwqR94;C6FfAcAZLv@+$rf;3; zOsw;}2tLSm;9C6mSv6myua<8v-5FPG0`r>Ees6p5^df=Cn4UW#wz^&--|538b{`Lq z7r?wHPX14!G)@!Y!G{H=4ABgJFAAo!L;VnGJKj{;1(YA(DGJ-+K`dP)ntgTU>!&cHWsJuf0<7mT4D|3f4|exQKQ zKn8YoMgfI`Own_*I?lT;n|sIR*%0`zBo~!`+B;=+eq&PA-l={3+TgX&t!mOrhC;wo z`=zfakAE3(6e`uss_%!k-r&fX9o^4 zWKjWkZ?HWJN=lllt7RfM|9jhLaH0BMSaD9l^`8c_sR6k4jANmFd>vf&^RpSd<^IZV zpft>!J(I-feuuj=fP=n#u)+%qvbI*zRE>{In}DYOQa2k82s&KHXaAKE#t@{Uq*V1- z7f$A`siUI=8%#M|{JXI; zI!_D8Lm1tNK})q)do@}qkM~%5nMInhK|J~`n)8C$57o(jlIM%FT^LxQqkBwUV>yu&~fiIh?OT?@QD2@yV77Db_ zOr^=ljzvQmGT05Y@BGOH;lF+{e=2+YE-o>bTS(KC?=^GufJFNI`5q|U@H7i}!M%OhRk_T<5-m2KT26F) zSv4x4g?W28ow26|51%`K_*uDnO^dYaO1RczJd6Khdz_jwwA^CkzbC7HPSN&6y)lgC zbq9;DrX7ExlXT3?O%Zh6TG4un^(0NPRYzU3XUUcSlc53uGcj&Fh&}rgTdXLGiSx}NApUV1Lm#24l zbX0$C{P_y+tN*=?lPAEgdEx|fFjxcF!zpapU2HU%mB4UlRz$n(8q3Qh_Q#2wwL5V} zI%_SnN%Zwp*oxGww1yZcunh-1ua3szP2UQ8K|OR|zaEwek3?=|{nc7tRb^DHD};%S zo$ zEjL@?U%B7vf|?9WPt*P>J>v&;8In&ctl@_W;%g(LY?$SL6e|cw>_Q4-zW-t50QJn? z{(g2=$^D@Spa&Edg)sQsU)+FDzk&ZrM_v8?OvU=#A-PbpwMC%vMP43NE#Lj{lAe!7 zDTO21(j*{dP#!K?S&>g1{V82P+6sAkJ~9iYdmIEd05Ok>XDe#UAup`DL zE8_ZceF3N44V5?1j3GZ?>QOQ@O!@h zbGoQjzopr>awvj8LE zSyx+Ai_6|tSMFCo3jcLgOKiLaz+%7zcyfXfE~oqa;?;3XtCz%oFJArc#Z7Xam@76f z-@N&VVb+^~z{Tmns15k7*Vka;KyVAEx|SzNMz_)}9o)T)u1DWYKy_JJS8Fi<>lE*D z9l?9|E&`QcqG&c=T73(r^A{+Ys%a4gz*u!2!$KF4qN*a^`hTYG(L>;WtTZxKXEyJwxhhI|fx#DXLgObuuMB=KG>rvTaQ@A~>C&jbpX zgVnT68?*KJvs3ci6dtFIKU=LX5LT~V&bs02JxQn3B4t?~II z;2rQ)=-RADFM7JM!*R9kXW>YCD?L-Wxwo)|#T*dbAH>5Gq8~J!e-W$+R@Zz5V#vX4 zy;>r;PS?;!P&KQS-jr4-Kus5vod&a)JiA83hy-n|t-jx6?(FW$rDIa;zE4NcLup)I zCr{S`~6Mg+E)HXmI>`2m@DnSFAd1l77E}@gYEaV_za=)Bss1RpC zZghS6sO%(DSGKIFnG9Ldp< zz{v@L+D-Q-rxX6?W}Yq%t_}`pyThBOZiR)rBf3rV6;|are1M?aSkr!QK3%Gto}PXR z#ES}X;JO9AL|{A8;Ci{clr3IW(^IC~s-mrUyE8WNf(jHQK*?(kmp<7$7;7>3-;U&) zfeq4(nRYMU8>sij(p|LN>Zu#5A(3E^`~6h}{|Xfa7)H0ciyt3%pmEe-LB=3@(@IEV zGypRFr}RT`9UOW%EjLbnU;mj<#OdGq@&PC|NTiZ>*vaH*NFd{>0hJj6sXbm7@@W?q zPQv}!eg+aL7Z;9m4O8v&tcha93Dz9;VgPQNXbdDcz5k4fSzx8g5bA{wOfqs)VFxz@ zW+Qt2t}itj=Kg|emGQBq%f;R7 znEaurbcBIG8LE_|8e8B0_H2k^8^LOMgm9+z2On1bgI*WAxy7)pBUI|m|&{{mYXqD(R%ZzvbM|gwu!lAF)wS!ncQf5=E#%R&w?=v)H4vNH?nVFcz=fG?h zRC6YJL+a>O#VCNoVVeMIh)m#k1-m#mizqBEtjQ$=4q8jRb+wdtqW?hF{EfBYcyqSg zUl4(RdU3+r;FUvhd9B8WZ6ep>Y0B#Z`$)N?!`Uq+E49H4-YP=;XaDUH;BA=guHPVD z9md*yEyz<=Q3ksh##`^M+1Y|{S@67qK6g@gKEU- z%W20GGXC3MeP+<A6>!!t3mDA;3_ z4S;_rTWYnsf*ZKmi>+FvDvGzEKE%t;(9EhdKc7w<84QZ!+@S$0fx_bAfiaRYE1Ysr z(*2X7^NQxr<`kV_2bdmfgB>?jT3QO@_l`EDoQJuS%_(($rkfc@|0OLgui=Z!?mc@> z9Q%gue!D)c72Kg(Sy^c{c7+y&^j8;RfYfn0-aqe82lK&E^NtUUKtfVBVG|1O+E!A8 zKvaBTHja--vgth3{}w(52*P+CYch?0U^}N(fPIyhZ#F$OQ|DIMkenPEI&qf!8dQ(M zNQAuJx1hdCP9Lq4FJGo$cv;*c>%inoj4y`l+ZpTiib+Rx!1^R~bl9bDCZC&MEQj){qtD}QC3vdqK z?BNn~JpM*z3$2w!V~$()H4SF}B^{~FjXBU7TOJ-hxzJBcPKJg2wzagRcdZfQ;Oyu6As{9L%kL3qh`g| z7l5HgnVtF12RBq96=$r*C?jk0>t+Nv$bH85XV;Cvq4L_zu)6hV)?Ke?P;i6j2_zlP zX~OO>qRi5zycYUhX0Uk+Qc-Vr0zTggbuO779{!xQOLhnViV1&20;^aS>D4Ikz2Wun zJE^M%dNrs_g4lBl3vVc>gw@pY35|eUHdl50MORlqcYL-N(1DbT;DsgrcVcu3svIB` zA>9FrMSpX{<)%YDnJh3-t8c9DCw!UyVkNk#N5A_1)pr>xEAxU#qRmzy_t!3l^S-@O zEz#7|QVT=_^Z&ZlUxwHH9XD;?kXD1SCZ{N_H$fhc%$BsPzV9Dd}B23d$Q{;XH%C7=DMUm=1@W>JoPsZE7 zSucAuxJzI9+x_ffm!`pJ;TQi&NPexI&uTOv)B7} zE4*lswkK}&=eLcgI~$KAv;?G1PwkzRtf%FqL7&zoo(%Mi+(|0{`tc`R3(^f^BG+ps z8m97~2zPeNKVL*epG(#l{zXs50q#>x9)$Z`PM>+ZIe)btw>`FWpz}0`$dFn|JRa88 zmX*1K)Cu&lSxUoOTUs^&eFP{V0-lecLiQdGY-(Jut;b{Y@ca5T9avR2Iu)aUv%k5y z)7>f@5LxwoR%%-jHGtcbUWV>*j8d=h8F)L%$%lM-LLxd|%c<{>GJ)Rn+l@sYX&891 zgCvGmccy!OZ>uI$oAGMzQtOrS9&lmG;cKWozu38EzIB;=NR;By0r)AQr8o}sozJzXGLIz_gV18<>j~T@Hp?`haKFCO})E1Y{E2bY)9bJku~e^D%T5s zf2`Rm@<`{cFuu3W1mhbiGg~n>)K}I$FMuNj_+6;@SOGFK&}+~NQ~C7yeXKoP_ClR3 zS=0w0c*1NjszVAZ?Ar>)+A|YEq@{W48?ScfUXucF^+=$uvWkL6l~er}D;67uL*KXA zph3${bg`Zo2QIC2#l5(s zdTnORnrgY>zqN@HdK6R@ZB-b)3IXqUkg2A)zJC4e8`yh*`IO6jd0^+-k3QqIWPd)Q zNa|HOMXf8jH>fg_k{Z`~ky+o?3)UXj8L3ZC|GvJa$dX}N%2_1y`%E4vlKXivReLeD zWmXv82ZCAfik5br7~(>%^|gq^)s8Q~fCKpjutu-0H35&kY4x0dgIpW53v&IG1!x$( z7LSRhMPStgZmJalB2vbLyDb~&xdSC-moLjs1)ynkL#=I3BCYXVPmu#b@{HGPPNLyb8+n-c3p>|7%#UonBTuJQ<8( zQ&MsobN>2_NvHXilnJMknGMfolG$fh$x^Fpp!2Wh^$@t22Jm49>e`0iTGG>V>-|rv zBW#br(@6I9rKj<%QHp|-c9UE$k4x+nNUJ-(C&N9q@XlT-{>x*G?V;3ggV{7s*KA?| z;|dKVQn~>Y40w3aEs9W}l$|{`7bFJKV`G$~W1^W`T5D_S);{&F0f%Gfb3hxGl9H`= zqAW>qO#uHw>St+X^|X9GlK7Tf^tSk|u)LlCAmu&+xRih2;TlGdvdR=yIR1z+r}bRD z(e-L(p9Qd9x5o`gN=cF>5^S&5@B_EsIyyRv0(e2S>A_%FUg;}MGwa81hKo2bwhIOxF$N>a+D8kG0 zTlWhApG($9o%M!IIsmWir|oCbc_}?Ih>7$Ju;YMz;^mj)AT-0uL@gIOmb)91 zw;1v}Eh^66ATc~{YOqh~G!g{L4svopZ0j(s8Z@tNQ!rxGX=XJn$N)fEiCF-001CwP z*m#QAN|nK_wEE34^o*>%(VjfT#Y+b*J*O-_^fg}n)f7muu{s}V(78<~y$ z`SQxha5So617aNN?lFYL-`txD?90B5O`s>xj2vX-SS*=>sGkpGDJ170o zNgO7mZ5CR1fjH(~-_YRE z=z0u+w7g+W;^Q#STKU<2G|UJUASR8CiwQ?_a6xD5i;o zQ`1v`X>H^187-!Z$NiB&;7!Z#YU7y}FF<`i3eyPP9-dL0S@hZXV(Oym>0kQKBgyET zktTRxbB*#E&>}{LWg91*s%rq7%LrrLkJ1hU9mwqf!P~`YWnr+puz*N9IW@45Z)WE= zS{lS#cPs|mpD;T-!vfE|efFF8>bSV5EE-+Q&G=7gP>{Dk^HXWZLX90$bJ4tze8#{g zRes)jer{f_%J}Yabj%6UL>2321>OIQnr; zw{iG0(gOo8Zef7Q3Gf-bS4V|~27`(0CUf6JvA4E*Mp7fBgK)gbuw!UShK;M}cAdUL zM39z#6Q(C#c*Cl9Tgx=rya#8m`u@GD5E5X(OYx-jnUeZ_AdsU>^FFy&NT#uAKi~$4 zzv0J|wfWXly=LcgTPNk?BmS-7L=!V3PjHXle}ht0WxCH!A$JeVlR-Ca&fGqAtSwcnqI5L_)*wz zR1gssr>08c_d(khyaS$Vz9@-*mgZ3xqF3b=nFeC2*aD|5B~5&-B(1-ztAGB2czLwO zm|L5Np?QGi4igS)EZ*-2G;n&&lG4(e@Oa*i-OzaJ6t z*Dv4V?O7sadYB@#@FswxG~g0?np#yjcK8@r>F*k+wB;H4lHw z8C{D)i##Xnw8jC6~~p9bhO_J+hJfJJrkZd9YG?6quM!GJ~jPggPl^EQxJp} zA{}(RvBzjJTL=J3+06(I?w$QbfSau&A|j$Z`2A>E$B#diN=&Tv{-)=9Ph?L7n&5$d zpLI}3@X_8T0|BM2rFGL$s3I)^}o~;?iQy;Yj}!lzLrX+Y4q0kO#gIL8@>3{r)q}T2xdtW6$O(_Hgod zSHcB#jfV#JzYl?^sf2{5*}z*)8iyY3o%oPp6?yiNyUW=LF%Kpa3mc0@)mq(7V&YF< zc{W>_uy8Y(^zr)^P;vN+iy8a$9_?(UC&lEJ<_@hnuvS(w4AO%NGI8()4z)OPr|n|B zjmaFT`9c0P{Sd>9-6GmsXS=ln`TY+?1@D=(eivBiYcn42>|)FjJ0viw>l>JoJ^;&q zUxJ^A*ZGc{^Fy;tJue(Tq=GJw<;JI8Wb_=d1`&^OmMW4@haGqz95sfs6yH z6T_)n(9~9H87No&Dw!R@YW!(bS^UaHJ^B^oB%1^cO_)zW{-8bJ_Jto>+`YvyiyAe~ z+VN???JXdJr*z?*D6x(6s;f`WOsS3U#yNR7%ZB^cyMI(r;0>4?8#zJ?7O~TRLPW@W=5~K2DT$QX(a}*E;xHmtS%#i)NgCMm*BN$#8E|y+d)@ zn+OR5=a!11faPH-b@$^A?%%hitfi)=syl4Vi5VA*Ow0%5=aN(czt5VcFIuV{OkJjH zw75)V%S)3H;H0I)$NPF49hllDCMSNW*StHLmb<%`r?H8nqSE6)W20d-9PaP`>jf|{)m7EiRnghp zyt#qP{@m+Q>%vVeP{)Lh8#%%cJyQqu56Im}?w&)Sl%oju6YkeL7U#lzi1E$D*FX&t zl~(0oe|I%C>Fe#4mPV+BpEzqGcS?ZU$^34H9q5_x-SU#sIIa3&;?!5m%?)*&%#b!z zX=xD*Qe_3n{G>#7K}}VKGNIfez;fc`B=d`n6J&z=kBqf3ht0LEfB9`6{L(&Hk6vxL zW#F3Tb0IiD4nAIX{ID1uZRnvgwfcyVoRL?WyVTyXZX?{}x;X+~wd>YpE@)=X+vY!MYE>c&K0{ z$#4SjDedR6R${BO>(hD7?I`##U}zR$}|4Gn-4(0#-W>xB$*i{YTJ zj9%P-2s~1oBYcio`QpXtr*-tu=!+1A?bu}Yhm!P!hj&(MPF=9*2AKq@rKe9ZLK-Sq zT~1HiQp!u7n@!Xy;rYjJz8-USldK>ifPl#i7GE|>CI2ePizgdL4O;edi`eH|Nlzni zBY^Xy4VqO}mWnf91`T5mkSs5fr)p+Y{duWsHIGe}RVs`}{{;@{zHszMAJSTD?r zM0;+`gt?g`wJIuDELmuej+lunrKAp_P3hFaW%G@{O<%1Lt_aV~nZo4XLlsEqI*col zNx8{uJl{4F78ewS6}Jsfj!I&&u(0arxb_V60#oRkib{II5ZqeVq-mXpm!2SRJ9jvF ze~pPln9$4LY076|?LFTR-iKO>bTbnNPjp$w$3w*@+n<9y^(%k@?8rS0?~s`F>RD<5Ae)Qy5p zJXe`oe|KFzW#kIKWud+&nB)c;RrJ!c{#LrxC{RKIsTQ5l?B0SJUZPH^5M1Z7Zb#3DnIgTJD zWedoQtH25Y92l7*C?oNKfh*aV$d}Ag#tI*RC~C;XYpSc$ym4Lq1Mea5Z8r@8IJfSD zpm(><-kzxM&R>T%U~t#|Znw>E)7Wl%^S7^B-<`kM5ZecY6ejM|t^H{e&_`Lda$%6t z&&iRX>X|}V4vu0w$Pf3g?|hMxPk!rlIEx9`(wve=-e-Ot%qmByCEqOs{VO zhYkj!6(E+5F0PX!lZ|B5(8IM|S+J>rn~%S}P#37e5vu+`?B16WJb>AP^%5HwKHic! zGyB)V!lHxedB+PTU`;F6*CF)Q6^J^36Kq*Q$;l?OZBH>42vGj5^FcoYz{;koDVv=n zTW0K!FWWCo7O$?MMUoIdDsOq7@FyZdM_?K}24Hls=)5RrKex37xFJ(_sh@w)B!2vW zPF)RMs8$!PSYC8vENQ2CwxI_F^|E?;*6*`rZY{vh@Ct|Wl@0k|$Nk%ktork@0!yZ_ z1`xP_ru_6#7Lu=zDS1rCU)}=kTCyMdB}gM=o~tUqGqi;wCMfxHVAAYPuXXJVXj(QY z_WK4YLG~ww6~$s2fS)ac=PIemcSGjN;MMlZLzm7S)NGdD^ee2nCl4QNj zl0W{edqMb>z%Bd4<+QrG8klD|*SPG@w+^ixf(yrofSF^;Ex-Y`p01Tc5OM_IX*&T= zN3gGRDk`SUA+n8WfF7cc@DT>k1)pHtI(J3TQ~(Az^5Ov?%Z@Jq`6)FIZ?#5!n&hM5 zvD*cO@T=GZJv|Q7(GQwXp&44=@BxwVe!wI^`5Eomin!R9B~!9cEMSluyi_Qb`7 zl5Iz7C>?mU>$+_Y8y`R<9|oP(*1Bb9TQcR9k`rf_g^tb!fhG%UOCYln;<{xpkW+b2(0-qvVV`_+hY}Qi^7C|bby^n` z?`&GEsEz5F8kw0{+WE3AM-+u}4|eb{2(odr%JyB<)YQ-=h)G>OX@9xJ)vZ+rD>aYPK;$;pedsY}weXHCwOyEosQONU-vQ+40a@=%1gK`KFMC=s59s7l}} zVdpr!4qzev7|99EXz@0(x87J^4}J8rnBV^A-;$eKPU9C6i#;QjTU+K_Q2i!vfe`6N z?-gB5*FW2g{f5ds7(ezF7osw70Zr<*zM1%|mxN?FAv-oDCi@z&eAT&uK#u*Q#f!@| z{-d6DVhZ$;@C!?NA|Ux5oS$?Bw$Eg3ZDn&;bFKNAWg-I1((xytJ!!Vw)XdBb$dM)I zBOzESg|QZB8WHJd_%CtBl(jPWo#K1Ol?h%mDF}OBuC_1) z2WYJSNzFaACjqU~IXFr|^+{pKBZ%}T*%}`Hq2584SgKYR%;I!Jqo~&fJ>Nb@i1hYU zeq>RR6`6oIhhJY+05H_(?g7${ASp>g ztN=y3{bTb#BJNk4Tz)qdUOTnD@84raP+!}QNj_k$iE1C298KqS(LL7Vx2pFu{;2A( z*|{)PVp5@V@c@iGp}7=4fXm;|Jv-<--5D}yd4~mhLi+k#o}C{4j*Orsa=04UN<*K~ z2Mi1jf=2`o?)pHK$7AmNS}O9Y#e;|@F+Oj7fJH5(T%7UIr+3;s<_4QU?!k56sX++j zxBe>Ge+;mqcm#ogH1O8&oUq>^fT**6_;xGJpC7N4oyzC9JTjJ#OC|hS6vN7@$SH4d ze1a63t^nRwKF9(DDXoogz`Odj5xgRajw~_fOMNUNg+*sbo>%bW4Wq^9-1;Zg;vm7m zrQ`;3@!}CYS^&vw>8K>ZzAre3@?v20g#Rjvv2=NI;FnilZ;F~1qa)+0)A|B>ODAxp zPmD&NrNpyuo)jfHk-~kCYoS??TeDWs+j?)}8OCC_x`B#{t^vRTt9f_^EvhHYB7qQy z_V~C^J+v?+BzoEJ+(*aA@fAF|tf<7w$V`&HH`b1XgiNcpGSnHQBsdSnfUkG1%tt*K ztkw9s@khlcM3ZxG|E)A#RCu^Tj`QZByAaSn=LJ2aQPq+7=XL(6MI=4rH8~DvMsU6r z6>eR1Nlxm5_(`e)ZN2q3ib>f}aB6dbPKRCa*|e9}(MVZ?HZ~DP`==RhD|tbIAL* zdL&|C94y=u`{8GB|L#)xAZ5_SlXL-dM+F6iev*zYL(7W8$WtG54-ZUQQl0fQBhByh z_;k9)t2O6IxM_t=@8<@mykeh=A{q>gUrG_}Yl@GlZW+u$vMf>bx#mN#^-|sA(8n$= z&e&m(7Z_h)exu@m@pA($0Uc<-UUE7A${!i@?%A+K9uON_7TEXn3;NyZP0ttnD z1c%=TBNG!|%eFU|UQN5ZR4mqlVQbslSExzR7fMw57-FS>66*G+1I0hUFtH{_QSRoU zdTd-SvJm~n!O?YeViIuE;Ly?zvaXrCnFkt_V13x@M!)NnwG+t$u=vU&i-Ffiqi>3~SlauLwQ1d|_sLtxrmvi{D z`oF?|jCYTv{r@Xr0lG=zuM2|zJrDT2sONv5{C|G>rF4hT@PGeC^uho6l4p1R=S&y} zYX4U+==tyec*g_+sLD!JFokqvbXrK?P}y1PgivvS$EBgcW8I9WfeS(P<#!KT&(zoi zFCS0zM_q{zj0xSZ`FO;!NaA7v7qw|&U3aD{(9i%3OF&?B6pW_T($|enN`nG!Jg30I z)WX!-RxGo+z5HFA^V{J5->qK)0|NogFr!;~vtQlR$Lgt}5m#ChE-e82#l^}xIytJT zq49y6637O`?lG{&(R=m(S5D9+*=1Up>Q=sW%r1Ck-fHJa_fnB16`XEydx(w5_k3d2?OyXTgc76pJ4f6_JH5i5O__2NmfnA2qP8Um0tm?PSEM5MR`=(-o|Vrx0ugJ`PRzyk88t!?_bZIn@-s<)>wkolNL(uITE#=}^wz;hC zOo)@Y9}FE95>?%;B(86~9QbqC*3WKqbi5=7_(6^e&mj)wl_V81%%?#m4UMo*rANcl zK^s-WXE*YEJUlkjESFJ^>t<%HCEBMS$oUOgJZ!CPtBgmM^VXDLLfDbthzsqE%m`ju z<-~o!yy~w$IWaL;WC#ErdC!kH{#+-bQ2T!Pkk?E~herqESl93kTA=C|dd|x`k(N>? ziypx6FMs%-39zUqHzaM&UpZB(ZS)Vv8;B^|KRtC>c+RW>qPKc|&U6gym+u`mbB(@W z`c>VpuPowzb28c{06gh10L14bg-1q0@`O^T-n@>6VtZSklS_d2;^=gz`%h}Jpif?t z^^QTH^wR8Xu$fip?e!!W>h<*nwmb2Gz(3^Og0B_q%(k}Yo7vmpx{R!>yO2$j_r4CB zjB?ohnvB0}J<_Gnkw5D$#!CTAi5(hhH;=pEc5(n~y_LyTy|rB^tqKm7mWcX9xhv`F zZTvawv$3WkAG}CcN>6U}N?^=vbK8O;K-zWdr-1m$wEK6+gbc z`N)ph;;%#>-9fYb(c`ZCvsy>el);Tdbw+bCeHt=4(}T4{(-!|Bvx>O5IKWo?{3*Bb zN42f}<+n~VfEe(W(#svt1Aq;Oh^IA}1E5h@M(U5;$dPEmo3f!LhgSh1a1(vb$o`e; zH*KliE1;hNi3Z72Q7v_OXW#cXv5$zFJUmtyi61v)r!+ks^gfPg6>Ec}QB!}AmpJzp zNg32siAvlBW1=1JYTBS|!k{Os*=zcWgoIe);X>A=cM}1Ghlr>`%t6PdHhOuZ+rQTs z(BN1pH9U1cdFsB8vM2PLR3utiSsBwxKVJgP?eBxz8@AvhOr4g8gp~+tgB2Lj;D!Rg z%1Z)XZk{6tBr`niyrV-B*L*u5#2oS&MEbbw-wi<9D9J8kp96N_b)Gy?#D@35=F(o1TM|1Hq-S!*3}X1B2j?%yz2+l5ej4p@ zwXN>`s$h7lJG0UaTI_4voB_eg0#k=$gAn%X<2yb+DU%$W9FO3hj0|kp?JrHl#Qa*9 z-1%7`%p43Wfb$(a;s$G4t>UlE5xYN}^uIHnJW`Zbl*F>VK_h)ZA;Z>maaJYw?j@-E zQwI+OQw&01A8@1vMFr0SIQ4N$e`s+g)xFCh5Xta6{c`w0|!TkWGZ5H4JZkfCkwAIb^A+jEh2F5AiSsaEd6ap@Arhk+=LUTA4b!S8HqViu&%kOELw3FI3rmX>!v z`O7o8eRQG~b+tLRu^o$OMWjJ5Pq&&p(6zt=2xLU3l(&f@mVJfC=IGO{+5C-sk#~Ig zjyn<5`(<{3Eg`Z5R=($b;ntQ(N#sE)g~UoI#ZM;P%L`*f=0jjW$SElUrNqZ4CsLj2sUG`!i{eKK7bMYvum|-jW53NY!Xg7`)Ma-us^L^{g91jH;>>8c&pn zP`r_Toc;ND9zZd!pmz z_0>G*qn&`@BM&MpD!`$BQ*V!)A1t3e@t8jwQp7_k%9HLa#YnO9>1k;2I;=8tDOuWk zi-SBl({G`wXTNi2ibrj%%wV2C1!d43D&sF(y&b^HeJf$d6clX!8Kc(?l6xR= zxvG%DT3hukC@XwA=l4VUE)K@|$S>C6|I8y$ECjg9?JEI^2Z0T*%^yZksA;MY5PA}b zQXHQju7R>VVc{uuJrSp)*vg|ylYH?P_}obYK+ZI5>XQs^jwB-gH|VHs;7(0BTc?!) zj7fD8lhSYnUI>L7XuzEo=7WNQrq%Mf^nQW|XSJty1yIrW5Rg|$DN-Q~jVgTCw&TJQ z63HoP1${ydG&nwP*+CoMPR|ly1*qKI{KLvJ!B~-!lKn{+Spjr2QzW*mf?Sg`BaUd2 zik6;QyM-_2A1F?U$B~79IWklt?D{$g?tZ=9ku-LmS$B9y*3LQhO<`RzIiGZ%|-xSu8LxXkX9C-~iHqnZBE#66x%z+evVy(LU^7-ZqlN~d8 ze*nIUjM{4h1%UXtaKQI>&kFuY2cS$*GHj$A=>i|D#Gu#vV(1-8inl-|f3g{ZzrDVO zC=KKH4I_7P0d~NbA1?ZCe0&(P_FLSKWiQ24Rdjj_O5=ENr>E!d(4D*zrKMBX(t<&+ zJ2{GS#**YoZ%|;Z=DUQWcjOv8M{7z!~mJM);jlJTq!^Ka(KBn zi^emS#HADg+6^L;?47!Hpba8`u=80DWnQ0b7ya<}-y+J7)zTCf4UO_O$A?h4nlPsQ zSve$tJQ)aTTW$k{);(|ZU=p90%=@j5>DT650pQ97?AO^}Q*i8dordFGIG|wh`gcK@W!|F+Xhyi zmmR+jtMcU4G}YgF9YO#)8}@tUC61i;ZMtl(c5+I;Hh@}Hbds*=@{9AyAU##tQeb7h z!I7T&-9|_kn{cb5ZERy|`Xm3APef6hFyZ@;Vo+3@-AjW5Xas*6LojP@HTbAY@)Q%l z-ok$r9IT~G-Teeg_2yd0whYaK%P+=%w?z+;rQ!;}bR-fgrh!xZxmyTWjc7M#@#t39 zciUEuGg{muXyHEmciV?YAA}vy=mKWAGBot7mkQWY8ZKz3Q@ad=J(pYWQ*Kh~Hxg<> z)fl;Zhr9Y%*WF)2f*5TO)t38frfb~N#Gu>48W&O==inBzkF(08vhaEUU2!g(eJwIcp_!$`tqbR zrLwlNa^J9X$w6dw4VU^27Hs-V96wrXa$YRDs1Lq@&YVnG$J0V zu!RHk4D28`4w{zcH`^T2X)JrIPwy^dF#Px3)#zFr$yB@jgKt!Us7ouW0((Ra8`o_% z5HB2f6Pc{wl9FQ4VS0H1%l0GC4Ck$n^6ia#mW7eoAL9y2ifz4S&M0wW5ef1(#Z=*# zgbc1-HIl>Ci>la@6D7CoidQHT1E?*c`cX_ugk0p;MoIz~t(;}BxCL4pw|XBZOFwfx zI}yhM#Prv%jp*$?N~bDPvs!CQOZtHAmUHn$fc3pnN|#?e>@npIG6J+FkYOa3UtDbu zrfAvEe2=|HF;QC`GaT+q|=hlwEP$%w41j2MRAr^CrOtHyt2=E=za!4hqM2d=`_k}60LWdJM}gFr6=gz`F+`@*mM?(Qt~#bl zy}Ji=SpnBBr0ZtAg@tWSR5qgF#2Pc8qo$>vgQZb@|tS*$wDr9y$6?Ve;rG))e^N?TezSKA585bNw_UDcf85ceo8;%uClS(^#duhHevzwYW(ziyU2nZtV^Yojn ztzu1jDtM&dHx>}o)YhSr?OLpdDRQV^ZJ8d?>@U>x$m0x)?*IGnEQ_ z&_`on-~pBLd-V)r3PQrXu{4f1t!ACFQ|CkqpAo4KWu><=VlCRyg{np&ru zr;n;kY`^g3`@E1+lvR}dXw__TWJ;EQ@1TYr;~vJ1iig+y09{1t7y<-X7TpWpn?=gZ zcIMXxQU}&Z3oX{Tl_2(4pz@Rr{`1Mq$wM&@iOyE|39VuiN{55)_wOO$i(``)5y+u8 z^{8J`zP`RrMC(H5zwVhRDxB!d_qOZkAP@+v8diC-{jGh@(#h8pEbr{xcNFqnt9scb zjQ2_-g|8)_5?gVnjE~LTpmE?8 zuU%?+HbMcbdvSH?s%v3X14c^=e=q7?wUO?5c`!0Cr=#Qgk?1E@Y$GX5J&u+R_wxPG~e)i6eYnon7UV-Q}JfxWizF z4?H|Q1r#3Ux!&GY>n@xq+Q_(;Sg$B4*^Rt)ndnc5yl~qPHodnsUlmzx zRc1PPL{ep9=UegN}3ff z<9z%GmlR?%e`av}(|o*)k!e6?wzaOfct>BwNZZ2VJbWTL=&hFW_jE^x-YhS&+yT7D zIK+^v3ohhl@c3K=-%B%-pMad0FytYdn8ra-SozoYyN7|a*G{4SA@Fq)5mAdKwv^6xGErVW|4>jWclIUc# zv$I3Z2_p)HMfmo$U0pLKRF(B(Q+ZIxCVY&AmrTF!VtD6oe*O~scx|mnR;qu){0QcL zA)3aWDOn11;ipryd&cW(mbZ4XP_^#whEejgq*HJl_Twm zAf_9u|1JrOt{DzDF(35Y&Q}V!kIB!&*SPQS!KDU&Ig^Af=ft{hHh2m{Fy!0U>e)Te z`X?g3Un8#EZbp0IFF84Jfofr2kaK-z=6ic`kF|-u?V)&HGfs!YG; znLuu4Mek`8Vs4H!cL2G&eWhoXmwkBtLcsoNs}v6}Cxh(`Ex({<@x)_bEEbnJwRoOp zP}b~m4Awo+XQ@=DLRGfawF%zf@$DEl0iTjhAy7ltAU*cwQO{5jn}%%eM=t`1gGzv>C<+hlaMz}q_0xt!xo-fTkpzBvu9GB&2@Zai)k zD*TMFqBh~F@0gp?($*JZV`8#%wV5lK9vwsIN$A;vGa(1ZlkTt|o6MZTzLj6Dwp8!MMNm4J}X zO!eVPHy(nWokt$a@y(8fwU?d(bZ?S+mU@S-b0G2QV!6Ul56Dg{v`YgSI|0%iL5DZI+Wd?vjsPjcs7>xYxu$?mR!2+a9t#-@)qlzh{t z%xeIq(p+a992}faOfX>;j=wCcU<;T5WkY2>y`7scm^o6@rF*s?*4EtS9k0Lm9Pi!E zG&-TbV!}L;%e_%C5mf!2<~#ADM@R9slO6Tvk8X&7-bS5uZH9y`cd$*Yon3P$%0!1c zEp07I4;8(KQ4=Ceucv-STp4 z+?*2!@=m^e8RXz7bGwk0_T!41(D3~7o{Vbr?|A*~ulJS(4j=&opcDVgfr)kmbWV5C zA@@vH^UN_ngDAt7HQ)_Q+ptELXeD)LXvMr6?45y~5)tvAv)bmG)fznVBhq^2D^1X| z$gA>J&6Gx~(*s-(Q-Jb5zmG?z(hd&pn^r2qSJ`qlz=~&0r1Ms{@XlCD8 z+rPqs~gWTCpReJ?kXQjC+A zVsK1J=t2}}=erVV|Ap zGVra%{AO+yjwv$ZcHl3YyNjfimY3E}j9zZ6b6Q>rlMNjkMdjuqL01u(!$*;#M}OCC z^vRKMLvgWepGTwpf#ezi;(H?SnuQtOy)B z&gGKANA7=k*)-wsi?js3W~P9Hqa#IVLs7}Hs-j(49jYF)_4YcTzJ4@pt-QHnDG+wc zL@(s6sPAoXvsIM5tx(=9;-+XEY2e3vEDZ*NE=vrf7Yo;u&}=Ymi5Tc7g=1N% zGg~S~jBCERJH*MJ_M4{*e2r-X;ev&*7Nl}lbnSyi+4L-!enU@co*AJ{H;)YR&b z^VjRMewTaY>29FSO~cBcU^TzQ_!gwM(Iq4n3#*-(*iIb+bX92a1g((L5Qsavdlpe* zGm_;~m=6`nMU;+y{rVL*Ly=reLOKi_nOZeVT_&-S{}nc_6+PWNM^@ zRP3eRPJ+p0(AVTAjf?n&1C>?9YFJCk!RW)QaX0$SMsD(r3Iw*vz4J>dqK>X&o%2H7LpUZ+Kp z*|^wG;Axg!shdxNV66vDr-g-1MA6&ZJL9q%78Z%RKF7r#O^A5}VsC5BBm4FGy7d$e z9DbvFdoZkA$Hi?|Gh_wGxgB1LZuDi)wzEOHf$iR2lgM0^oo$hc)mnV5rd$Ri^Barv zp6#wYrtIDV?8$J-4TgZt&0Te7;k65T%3ES~$W7&9b;V9>25yRQUKbY;5rZg#DA;Dq1-H$Y+I_q0H?*|jk{qX*TQj_B z87vl%ADnMiQ?~A5c&mU-595Ht;r`--<1TmU8|t5I9Hq_6%l%x` cAC_)0s;Z8xfZ^zee}g9@p(tJkG5q|$04fxX)&Kwi literal 90199 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�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 x5H0`vY`p- - Eggshell — stop paying twice for work Codex already did - Eggshell carries selected completed work from one Codex chat to another independent chat. - - - - - - - - STOP PAYING TWICE FOR WORK - CODEX ALREADY DID. - - - - - - - CHAT A · INVESTIGATE - search repository - → 3 relevant paths - → test timed out - - CHAT B · INDEPENDENT - PRIOR WORK SELECTED - continue from open work - without blind rediscovery - - - - - - - - - .egg - - LOCAL WORK GRAPH FOR INDEPENDENT CODEX CHATS - +Eggshell — AI memory that saves tokensThe Eggshell mascot and wordmark, with the message: Remember the work. Save the tokens. Local memory for AI agents, without LLM calls to organize memory. + + +LOCAL MEMORY FOR AI AGENTSRemember the work.Save the tokens.Reuse findings across chats.No LLM calls to organize memory.~80% fewer tokens**One LLVM follow-up task, reusing prior work.Includes failed attempts. Quality and limits in README. diff --git a/docs/brand.md b/docs/brand.md index ae71f3f..086b93c 100644 --- a/docs/brand.md +++ b/docs/brand.md @@ -72,7 +72,8 @@ the caption **“AI memory. Fewer tokens.”** below. The README uses the same headline beneath its existing theme-aware horizontal logo. Installation documentation identifies the available integrations and their validation status. -Regenerate both the self-contained SVG and PNG with -`python3 scripts/render_social_preview.py` (requires `rsvg-convert`). GitHub +The checked-in SVGs are the editable source. Regenerate their PNG exports with +`lake build eggshell_render` followed by `.lake/build/bin/eggshell_render social` +(requires `rsvg-convert`). GitHub stores one social preview image; the separate dark export is available for other placements. diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index 93e9fdc..1556c0b 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -49,7 +49,7 @@ never download dependencies or prevent the task from continuing. If no startup message appears, check `/hooks`: an untrusted hook cannot display its own notice. To inspect an installation without changes, run -`python3 /scripts/setup.py --check --project `. +`sh /scripts/setup.sh --check --project `. For setup, omit `--check`. The default project is the current directory. **Supported execution environment:** Codex with local command hooks on macOS or diff --git a/docs/demo.md b/docs/demo.md index 260bd43..9ce7b99 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -147,10 +147,12 @@ translated; full private transcripts and local filesystem paths are not republished. Hashes establish which local records were inspected, but are not a substitute for access to those records. -The graphics take their numerical values from the measurement record. To -regenerate the SVGs, GIF, and MP4 with Python 3, `rsvg-convert`, ImageMagick, and -FFmpeg installed: +The checked-in SVGs contain the reviewed figures from the measurement record. +The Lean renderer checks that record's fingerprint before rendering the GIF +and MP4. If the record changes, review the SVG figures and update the fingerprint. +With `rsvg-convert`, ImageMagick, and FFmpeg installed: ```sh -python3 scripts/render_demo.py +lake build eggshell_render +.lake/build/bin/eggshell_render demo ``` diff --git a/docs/hook-lifecycle.md b/docs/hook-lifecycle.md index 30f461d..4b28408 100644 --- a/docs/hook-lifecycle.md +++ b/docs/hook-lifecycle.md @@ -94,7 +94,7 @@ incremental roots, replay without revision growth, final promotion, and corrupt state recovery. Its deterministic hook fixtures explicitly drain the independent writer; real concurrency is exercised separately. -`tests/test_hook_lifecycle.py` runs the production binary in isolated temporary +`tests/LifecycleTests.lean` runs the production binary in isolated temporary directories without model downloads or LLM calls. It injects authority lock contention, lock-owner death, search hangs and deadlines, writer death, manager death, lost receipts, and malformed state. It checks actual `.egg` bytes before diff --git a/docs/lean-boundaries.md b/docs/lean-boundaries.md new file mode 100644 index 0000000..e9560c3 --- /dev/null +++ b/docs/lean-boundaries.md @@ -0,0 +1,84 @@ +# Lean contracts and runtime boundaries + +Eggshell uses Lean for its memory engine, native harness adapters, retrieval +selection, setup decisions, package assembly, and process regression tests. +Adapter code stays in its separate Lake package. The engine does not import it. + +## Executed functions with kernel-checked contracts + +| Implementation | Proved property | +| --- | --- | +| `Adapter/Contracts.lean`: `projectReply` | Stop, Interrupt and SessionEnd cannot emit context, denial or retry instructions; unsupported Cursor prompt output is silent. | +| `ownerMatches` | Accepted correlation state belongs to the exact requested host and native chat, even if a file-location hash collides. | +| `chooseCall` | A selected occurrence belongs to the eligible original calls and all candidates agree on its originating turn; conflicting turns are rejected. | +| `maySaveAnswer` | Saving requires an enabled, writable, active, completed turn. An aborted turn cannot become a completed answer. | +| `commitPlan` | A terminal receipt is journaled before its correlation ID is marked consumed. The runtime interprets this plan before calling the manager. | +| `receiptToAck` | Silent/unsupported output cannot produce an acknowledgement. The runtime creates its publication token after writing and flushing the supported response. | +| `removeOwned` | An entry not in the recorded ownership set is preserved; an entry removed from the input belonged to that set. | +| `Eggshell/Setup.lean`: `action` | Existing configuration and check-only mode never select initialization. | +| `Eggshell/SearchRank.lean`: `select` | Every result was ranked, refers to an existing candidate, is unique, and fits the requested count limit. | +| `Eggshell/SearchProvider.lean`: `cacheMatches` | Reusing an embedding requires exact model and source text identity; changing either rejects the record. | + +These are the functions invoked by production code, not a separate test-only +model. Builds reject `sorry`; the two `ContractAudit.lean` modules enumerate 29 +contracts and reject any dependency outside `propext`, `Quot.sound`, and +`Classical.choice`, Lean's standard logical basis. They supplement the existing +graph, lifecycle and persistence proofs. + +## Numerical boundary + +`runtime/embedding.py` calls the pinned FastEmbed model and NumPy's existing +float32 normalization and dot-product kernels. It accepts text/vector batches +and returns vectors/scores. It performs no retrieval selection, state management, +ranking, or graph work. Keeping these numerical kernels avoids silently replacing +their floating-point implementation during the language migration. + +Window splitting, Unicode case folding, lexical ranking, reciprocal-rank fusion, +cache identity checks and candidate selection run in Lean. Unicode 16.0 mappings +are frozen as Lean data to match the prior provider's environment. The new cache +checks both model and source text, rather than treating a hash as evidence of +identity. Prior caches can be rebuilt; saved `.egg` work is not rewritten. + +The legacy-provider fixture covers 24 selections across lexical, semantic and +hybrid modes, including long records, exact identifiers, Unicode and changed +text under reused caller IDs. This comparison passed with the local pinned +model. It is a regression test, not a universal equivalence theorem. In +particular, the old Python lexical score summed an unordered set; Lean uses +the query's stable term order. Last-bit floating-point ties can therefore differ. +No new model-task token-reduction rate is claimed by this migration. + +## Trusted external operations + +The Lean compiler/runtime, OS file writes and locks, process and pipe operations, +cryptographic primitives, numerical libraries, and harness delivery APIs remain +trusted boundaries. The pure theorems do not prove disk survival through power +loss, eventual OS scheduling, correctness of arbitrary model answers, or that a +host actually consumed a successfully written response. Filesystem/transport +failures are reported; they are not represented as successful persistence or +model use. Atomic-file tests exercise process interruption, not power failure. + +The remaining non-Lean product code is deliberately limited to the numerical +bridge, OpenCode's JavaScript host callbacks, and a shell bootstrap that must +obtain a pinned native executable before Lean code is available. The bootstrap +checks the archive checksum and member type before execution; project setup +decisions run in Lean. SVGs and the external raster/video encoders remain artwork +and rendering dependencies. The small Python project under `examples/two-chats` +is an investigation target, not Eggshell implementation or a runtime dependency. + +## Verification + +```sh +lake build eggshell eggshell_tests lifecycle_tests eggshell_package setup_package_tests search_tests +EGGSHELL_DATA_ROOT="$PWD/.lake/eggshell-tests-data" .lake/build/bin/eggshell_tests +.lake/build/bin/lifecycle_tests +.lake/build/bin/setup_package_tests +.lake/build/bin/search_tests +(cd adapters/native && lake build eggshell_bridge adapter_tests && .lake/build/bin/adapter_tests) +node --test tests/test_opencode_adapter.mjs +``` + +The numerical comparison requires the installed MiniLM runtime and cached model; +it runs with network access disabled for the model. Tests use isolated memory +roots and make no generative model calls. CI is configured to run the core and +adapter process tests on Linux and macOS; local success is not a claim that +remote CI ran. diff --git a/docs/try-it.md b/docs/try-it.md index 9096166..d0a09e3 100644 --- a/docs/try-it.md +++ b/docs/try-it.md @@ -55,7 +55,7 @@ exercise moves to a separate chat. `!egg inspect` should identify the sample's Confirm the file exists and is nonempty from the sample's terminal: ```sh -python3 -c 'from pathlib import Path; p = Path(".eggs/work.egg"); print("saved work found" if p.is_file() and p.stat().st_size else "no saved work yet")' +test -s .eggs/work.egg && echo 'saved work found' || echo 'no saved work yet' ``` The file's presence confirms persistence. Its size does not establish relevance diff --git a/lakefile.lean b/lakefile.lean index 9863e13..a3139b9 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -13,3 +13,18 @@ lean_exe eggshell where lean_exe eggshell_tests where root := `TestMain + +lean_exe eggshell_package where + root := `tools.Package + +lean_exe search_tests where + root := `tests.SearchTests + +lean_exe setup_package_tests where + root := `tests.SetupPackageTests + +lean_exe lifecycle_tests where + root := `tests.LifecycleTests + +lean_exe eggshell_render where + root := `tools.Render diff --git a/plugins/eggshell/scripts/setup.py b/plugins/eggshell/scripts/setup.py deleted file mode 100644 index 9b42cec..0000000 --- a/plugins/eggshell/scripts/setup.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -"""Set up local memory for a project, or check its configuration without changes.""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import platform -import shutil -import subprocess -import tarfile -import tempfile -import urllib.request - - -def target(): - system = {'Darwin': 'macos', 'Linux': 'linux'}.get(platform.system()) - machine = {'arm64': 'aarch64', 'aarch64': 'aarch64', - 'x86_64': 'x86_64', 'amd64': 'x86_64'}.get(platform.machine().lower()) - if not system or not machine: - raise ValueError('Eggshell requires macOS or Linux on ARM64 or x86-64.') - return f'{system}-{machine}' - - -def extract_runtime(archive, destination, expected): - digest = hashlib.sha256() - with archive.open('rb') as source: - for block in iter(lambda: source.read(1024 * 1024), b''): - digest.update(block) - if digest.hexdigest() != expected: - raise ValueError('Runtime checksum mismatch; nothing was installed.') - with tarfile.open(archive, 'r:gz') as bundle: - members = bundle.getmembers() - if len(members) != 1 or members[0].name != 'eggshell' or not members[0].isfile(): - raise ValueError('Runtime archive must contain exactly one regular eggshell executable.') - if members[0].size > 512 * 1024 * 1024: - raise ValueError('Runtime executable exceeds the size limit.') - with bundle.extractfile(members[0]) as source, destination.open('wb') as output: - shutil.copyfileobj(source, output) - destination.chmod(0o755) - - -def inspect_project(runtime, project, environment): - result = subprocess.run([str(runtime), 'egg', 'doctor'], cwd=project, - env=environment, text=True, capture_output=True) - if result.returncode: - raise ValueError(result.stderr.strip() or f'Eggshell setup check exited {result.returncode}.') - return json.loads(result.stdout) - - -def initialize_project(runtime, project, environment): - """Let the runtime resolve existing project/global settings; never replace them.""" - report = inspect_project(runtime, project, environment) - if report['configuration'] == 'missing': - subprocess.run([str(runtime), 'egg', 'init'], cwd=project, - env=environment, check=True) - report = inspect_project(runtime, project, environment) - return report - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--prefix', default=os.environ.get('EGGSHELL_PREFIX', str(Path.home() / '.local'))) - parser.add_argument('--project', type=Path, default=Path.cwd(), - help='Project to configure (defaults to the current directory).') - parser.add_argument('--check', action='store_true', - help='Read setup status; do not download, initialize, or enable anything.') - args = parser.parse_args() - prefix = Path(args.prefix).expanduser() - if not prefix.is_absolute(): - raise ValueError('--prefix must be an absolute path.') - project = args.project.resolve(strict=True) - if not project.is_dir(): - raise ValueError('--project must be a directory.') - environment = dict(os.environ, EGGSHELL_PREFIX=str(prefix)) - # A different project's setup must not reuse the caller's chat profile. - environment.pop('CODEX_THREAD_ID', None) - installed = prefix / 'libexec/eggshell' - if args.check: - marker = prefix / 'libexec/eggshell.owner' - if (not os.access(installed, os.X_OK) or not marker.is_file() - or marker.read_text().strip() != 'o8vm/eggshell'): - print(json.dumps({'runtime': 'missing', 'configuration': 'unknown', - 'next_step': 'Ask Codex: Set up Eggshell for this project.'})) - return 1 - help_result = subprocess.run([str(installed), 'egg', '--help'], env=environment, - check=True, text=True, capture_output=True) - if 'doctor' not in help_result.stdout: - print(json.dumps({'runtime': 'update_required', 'configuration': 'unchecked', - 'next_step': 'Run this setup without --check to update the runtime.'})) - return 1 - report = inspect_project(installed, project, environment) - print(json.dumps(report, indent=2)) - return 0 if report['configuration'] == 'ready' else 1 - manifest = json.loads((Path(__file__).resolve().parent.parent / 'runtime.json').read_text()) - asset = manifest['targets'][target()] - url = f"https://github.com/momonpya/eggshell/releases/download/{manifest['release']}/{asset['file']}" - with tempfile.TemporaryDirectory(prefix='eggshell-setup-') as directory: - archive = Path(directory) / 'runtime.tar.gz' - total = 0 - with urllib.request.urlopen(url, timeout=60) as response, archive.open('wb') as output: - while block := response.read(1024 * 1024): - total += len(block) - if total > 100 * 1024 * 1024: - raise ValueError('Runtime download exceeds the size limit.') - output.write(block) - executable = Path(directory) / 'eggshell' - extract_runtime(archive, executable, asset['sha256']) - subprocess.run([str(executable), 'install', 'runtime'], env=environment, check=True) - report = initialize_project(installed, project, environment) - print(json.dumps(report, indent=2)) - print('Local setup complete. Memory activation is not yet verified.') - print('In Codex, review Eggshell in /hooks and start a new chat in this project.') - print('Look for "Eggshell session hook connected", then run !egg doctor.') - print('Verify a saved investigation and a related follow-up in a separate chat with !egg graph.') - print(f'For terminal controls, add {prefix}/bin to PATH.') - return 0 - - -if __name__ == '__main__': - try: - raise SystemExit(main()) - except (OSError, ValueError, KeyError, subprocess.CalledProcessError, tarfile.TarError) as error: - raise SystemExit(f'eggshell setup: {error}') diff --git a/plugins/eggshell/scripts/setup.sh b/plugins/eggshell/scripts/setup.sh new file mode 100644 index 0000000..4e0c136 --- /dev/null +++ b/plugins/eggshell/scripts/setup.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Bootstrap only: fetch a pinned native executable before Lean is available. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +runtime_root=${EGGSHELL_PREFIX:-${HOME:?HOME or EGGSHELL_PREFIX is required}/.local} +project=$PWD +check_only=false +while [ "$#" -gt 0 ]; do + case "$1" in + --prefix) runtime_root=${2:?--prefix needs a path}; shift 2 ;; + --project) project=${2:?--project needs a path}; shift 2 ;; + --check) check_only=true; shift ;; + *) echo 'usage: setup.sh [--prefix PATH] [--project PATH] [--check]' >&2; exit 1 ;; + esac +done +case "$runtime_root" in /*) ;; *) echo '--prefix must be absolute' >&2; exit 1 ;; esac +project=$(CDPATH= cd -- "$project" && pwd) +export EGGSHELL_PREFIX="$runtime_root" +unset CODEX_THREAD_ID +runtime=$runtime_root/libexec/eggshell +marker=$runtime_root/libexec/eggshell.owner +if "$check_only"; then + if [ ! -x "$runtime" ] || [ ! -f "$marker" ] || [ "$(cat "$marker")" != o8vm/eggshell ]; then + echo '{"runtime":"missing","configuration":"unknown"}' + exit 1 + fi + if ! "$runtime" --help | grep -q 'eggshell setup'; then + echo '{"runtime":"update_required","configuration":"unchecked"}' + exit 1 + fi + exec "$runtime" setup --project "$project" --check +fi +case "$(uname -s)" in Darwin) platform=macos ;; Linux) platform=linux ;; *) exit 1 ;; esac +case "$(uname -m)" in arm64|aarch64) arch=aarch64 ;; x86_64|amd64) arch=x86_64 ;; *) exit 1 ;; esac +pins=$root/runtime-pins/$platform-$arch +{ + IFS= read -r release + IFS= read -r asset + IFS= read -r expected +} < "$pins" +case "$release:$asset" in *[!a-zA-Z0-9._:-]*) echo 'Invalid runtime asset name' >&2; exit 1 ;; esac +case "$expected" in *[!0-9a-f]*|'') echo 'Invalid runtime checksum' >&2; exit 1 ;; esac +[ "${#expected}" -eq 64 ] || exit 1 +temporary=$(mktemp -d "${TMPDIR:-/tmp}/eggshell-setup.XXXXXX") +trap 'rm -rf -- "$temporary"' EXIT HUP INT TERM +archive=$temporary/runtime.tar.gz +curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \ + --max-time 60 --max-filesize 104857600 \ + "https://github.com/momonpya/eggshell/releases/download/$release/$asset" --output "$archive" +if command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$archive" | awk '{print $1}') +else + actual=$(sha256sum "$archive" | awk '{print $1}') +fi +[ "$actual" = "$expected" ] || { echo 'Runtime checksum mismatch; nothing installed' >&2; exit 1; } +[ "$(tar -tzf "$archive")" = eggshell ] || { echo 'Unexpected runtime archive members' >&2; exit 1; } +case "$(tar -tvzf "$archive")" in -*) ;; *) echo 'Runtime must be a regular file' >&2; exit 1 ;; esac +tar -xzf "$archive" -C "$temporary" eggshell +chmod 755 "$temporary/eggshell" +"$temporary/eggshell" install runtime +"$runtime" setup --project "$project" +echo 'Review Eggshell in /hooks, start a new chat, and verify two-chat reuse.' diff --git a/plugins/eggshell/skills/eggshell/SKILL.md b/plugins/eggshell/skills/eggshell/SKILL.md index 3c1fcf8..560e05f 100644 --- a/plugins/eggshell/skills/eggshell/SKILL.md +++ b/plugins/eggshell/skills/eggshell/SKILL.md @@ -18,14 +18,14 @@ integration needs Codex; do not claim that selecting the plugin activates memory 1. Check macOS/Linux, ARM64/x86-64, Python 3, and Codex command-hook support. Resolve this skill's installed path: the plugin root is two levels above this `SKILL.md` directory. Use absolute paths for the bundled helpers. -2. Check existing setup with `python3 /scripts/setup.py --check --project `. +2. Check existing setup with `sh /scripts/setup.sh --check --project `. This only inspects configuration and does not download or enable anything. `missing` or `update_required` means setup is needed. A ready configuration can proceed directly to hook review; do not reinstall merely to check it. Explain that setup downloads a checksummed Eggshell runtime, Python packages, and MiniLM. The default install root is `~/.local`; preserve an existing `EGGSHELL_PREFIX`. Once setup is authorized, run - `python3 /scripts/setup.py --project `. + `sh /scripts/setup.sh --project `. This installs the runtime and initializes missing project settings, preserving existing project and global configuration. Do not run the standalone release installer after directory installation; it registers another copy of the hooks. diff --git a/runtime/embedding.py b/runtime/embedding.py new file mode 100644 index 0000000..b83491e --- /dev/null +++ b/runtime/embedding.py @@ -0,0 +1,30 @@ +"""Numerical boundary only: FastEmbed inference and the existing NumPy kernels. + +Selection, identity, windows, persistence, ranking, and protocol supervision are Lean. +""" +import json +import sys +import numpy as np +from fastembed import TextEmbedding + +encoder = TextEmbedding(model_name=sys.argv[1], cache_dir=sys.argv[2], threads=int(sys.argv[3])) +if sys.argv[4:] == ["--preload"]: + next(encoder.embed(["eggshell"], batch_size=1)) + raise SystemExit(0) +for line in sys.stdin: + try: + request = json.loads(line) + if "texts" in request: + vectors = [] + for vector in encoder.embed(request["texts"], batch_size=32): + vector = np.asarray(vector, dtype=np.float32) + norm = np.linalg.norm(vector) + vectors.append((vector if norm == 0 else vector / norm).tolist()) + result = {"vectors": vectors} + else: + queries = [np.asarray(v, dtype=np.float32) for v in request["queries"]] + result = {"scores": [max(float(np.dot(q, np.asarray(v, dtype=np.float32))) + for q in queries for v in windows) for windows in request["candidates"]]} + print(json.dumps(result, allow_nan=False, separators=(",", ":")), flush=True) + except Exception as error: + print(json.dumps({"error": str(error)}), flush=True) diff --git a/scripts/package_plugin.py b/scripts/package_plugin.py deleted file mode 100644 index 1e0562b..0000000 --- a/scripts/package_plugin.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""Build a directory ZIP and immutable runtime assets from four tested binaries.""" -import argparse -import hashlib -import json -from pathlib import Path -import shutil -import stat -import subprocess -import tempfile -import zipfile - -TARGETS = ('linux-aarch64', 'linux-x86_64', 'macos-aarch64', 'macos-x86_64') - - -def package(runtime_dir, output, release): - root = Path(__file__).resolve().parent.parent - output.mkdir(parents=True, exist_ok=True) - source = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip() - runtime = {'release': release, 'source_commit': source, 'targets': {}} - for target in TARGETS: - archive = runtime_dir / f'eggshell-{target}.tar.gz' - digest = hashlib.sha256(archive.read_bytes()).hexdigest() - name = f'eggshell-runtime-{target}-{digest[:16]}.tar.gz' - destination = output / name - shutil.copyfile(archive, destination) - runtime['targets'][target] = {'file': name, 'sha256': digest} - with tempfile.TemporaryDirectory(prefix='eggshell-package-') as directory: - plugin = Path(directory) / 'eggshell' - shutil.copytree(root / 'plugins' / 'eggshell', plugin, - ignore=shutil.ignore_patterns('__pycache__', '*.pyc')) - (plugin / 'assets').mkdir() - shutil.copyfile(root / 'docs/assets/brand/eggshell-app-icon-dark-1024.png', plugin / 'assets/icon.png') - shutil.copyfile(root / 'LICENSE', plugin / 'LICENSE') - manifest_path = plugin / '.codex-plugin/plugin.json' - manifest = json.loads(manifest_path.read_text()) - if release != 'v' + manifest['version']: - raise ValueError('Release tag must match the plugin version.') - manifest['skills'] = './skills' - manifest['interface'].update({ - 'logo': './assets/icon.png', 'composerIcon': './assets/icon.png', - 'privacyPolicyURL': 'https://github.com/momonpya/eggshell/blob/main/PRIVACY.md', - 'defaultPrompt': ['Set up Eggshell for this project.', - 'Check whether Eggshell memory is working in this project.', - 'Show what Eggshell handed to this task and why it was selected.']}) - manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') - (plugin / 'runtime.json').write_text(json.dumps(runtime, indent=2) + '\n') - archive_path = output / 'eggshell-codex-plugin.zip' - with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - for file in sorted(plugin.rglob('*')): - if file.is_symlink(): - raise ValueError(f'Symlinks are not allowed: {file}') - if file.is_file(): - info = zipfile.ZipInfo(file.relative_to(plugin).as_posix(), (2026, 1, 1, 0, 0, 0)) - mode = 0o755 if file.parent.name == 'bin' else 0o644 - info.external_attr = (stat.S_IFREG | mode) << 16 - info.compress_type = zipfile.ZIP_DEFLATED - archive.writestr(info, file.read_bytes()) - if archive_path.stat().st_size > 100_000_000: - raise ValueError('Plugin ZIP exceeds 100 MB.') - (output / 'runtime.json').write_text(json.dumps(runtime, indent=2) + '\n') - print(f'{archive_path}: {archive_path.stat().st_size:,} bytes; four pinned runtime assets') - return archive_path - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--runtime-dir', type=Path, required=True) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--release', required=True) - args = parser.parse_args() - package(args.runtime_dir, args.output, args.release) diff --git a/scripts/render_demo.py b/scripts/render_demo.py deleted file mode 100644 index 5ae2a79..0000000 --- a/scripts/render_demo.py +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env python3 -"""Render the recorded LLVM walkthrough from the published measurement record. - -Requires Python 3, rsvg-convert, ImageMagick (magick), and ffmpeg. -No model calls, private transcripts, or benchmark execution are involved. -""" - -import copy -import html -import json -import math -from pathlib import Path -import subprocess -import tempfile -import xml.etree.ElementTree as ET - - -ROOT = Path(__file__).resolve().parents[1] -DEST = ROOT / "docs/assets/demo" -RECORD = ROOT / "docs/benchmarks/llvm-follow-up.json" -BG, INK, MUTED = "#f7f3ea", "#191b17", "#5e6257" -GREEN, PALE, AMBER = "#285d42", "#e7eedf", "#9b4c2c" -WIDTH, HEIGHT = 1280, 800 -FONT = "DejaVu Sans, sans-serif" -NS = "{http://www.w3.org/2000/svg}" -ET.register_namespace("", NS[1:-1]) - - -def run(*args): - subprocess.run(args, check=True, stdout=subprocess.DEVNULL) - - -def text(x, y, value, size=24, color=INK, weight=400, anchor="start"): - return (f'' - f'{html.escape(str(value))}') - - -def lines(x, y, values, size=25, color=INK, step=39, weight=400): - return "".join(text(x, y + i * step, v, size, color, weight) - for i, v in enumerate(values)) - - -def box(x, y, width, height, fill="#fffdf8", stroke="#dedfd3", radius=20): - return (f'') - - -def logo(): - # Embed the canonical artwork unchanged, preserving its viewBox and ratio. - node = copy.deepcopy(ET.parse( - ROOT / "docs/assets/brand/eggshell-primary-horizontal.svg").getroot()) - node.set("x", "44") - node.set("y", "14") - node.set("width", "198") - node.set("height", "76") - node.set("aria-labelledby", "logo-title logo-desc") - for child in node.iter(): - if child.get("id") in {"title", "desc"}: - child.set("id", "logo-" + child.get("id")) - return ET.tostring(node, encoding="unicode") - - -def frame(title, subtitle, content, page=None, description=""): - progress = "" - if page: - for i in range(4): - progress += box(64 + i * 294, 710, 274, 5, - GREEN if i + 1 == page else "#d8dccf", "none", 2) - progress += text(1216, 763, f"{page} / 4", 17, MUTED, anchor="end") - return (f'' - f'{html.escape(title)}' - f'{html.escape(description or subtitle)}' - f'' - + logo() - + text(1216, 60, "RECORDED LLVM STUDY · ENGLISH SUMMARY", 16, MUTED, - 600, "end") - + text(64, 147, title, 43, INK, 700) - + text(64, 194, subtitle, 22, MUTED) - + content + progress - + text(64, 763, "Source records & limits: docs/demo.md", 17, MUTED) - + "\n") - - -def measurements(): - data = json.loads(RECORD.read_text()) - completed, failed = data["completed_trials"], data["failed_attempts"] - for row in [*completed, *failed, data["fresh_reference"]]: - if row["input_tokens"] + row["output_tokens"] != row["total_tokens"]: - raise ValueError("Input/output accounting mismatch") - totals = data["token_accounting"] - n = len(completed) - total = sum(row["total_tokens"] for row in completed) - all_total = total + sum(row["total_tokens"] for row in failed) - fresh = data["fresh_reference"]["total_tokens"] - inclusive = all_total / n - reduction = 100 * (1 - inclusive / fresh) - checks = { - "completed_trials": n, - "failed_attempts": len(failed), - "completed_total_tokens": total, - "completed_mean_tokens": total / n, - "completed_mean_reduction_percent": 100 * (1 - total / n / fresh), - "all_attempts_total_tokens": all_total, - "all_attempts_tokens_per_completion": inclusive, - "all_attempts_per_completion_reduction_percent": reduction, - } - for key, value in checks.items(): - if not math.isclose(totals[key], value, rel_tol=1e-12): - raise ValueError(f"Published measurement mismatch: {key}") - counts = {key: sum(t["quality"]["label"] == key for t in completed) - for key in data["quality_review"]["counts"]} - if counts != data["quality_review"]["counts"]: - raise ValueError("Quality count mismatch") - # The story illustrates the first completed trial, never a selected best run. - if completed[0]["trial"] != 1: - raise ValueError("The illustrated trial must be trial 1") - return data, fresh, inclusive, reduction, counts - - -def render(): - data, fresh, inclusive, reduction, counts = measurements() - saved_cost = data["seed"]["preceding_investigation_total_tokens"] - story = [] - body = box(64, 237, 1152, 106, INK, "none") - body += text(90, 274, "CHAT 1 · INVESTIGATE", 18, "#b5d0ae", 700) - body += text(90, 317, "How does Clang choose a toolchain?", 30, "#fffdf8", 600) - body += box(64, 363, 556, 222) + box(644, 363, 572, 222, PALE) - body += text(90, 407, "Map the path through the source", 24, INK, 700) - body += lines(90, 452, ["Driver options → target triple", "→ toolchain → arguments → job"], 24) - body += text(90, 554, "Sources and test locations retained", 19, MUTED) - body += text(670, 407, "Useful findings saved in .egg", 24, GREEN, 700) - body += lines(670, 452, ["Toolchain selection and cache rules", "How per-toolchain arguments form"], 23) - body += text(670, 554, "Local memory organization · no LLM calls", 19, GREEN) - body += text(64, 637, f"Prior investigation: {saved_cost:,} tokens, recorded separately.", 22, MUTED) - body += text(64, 672, "Follow-up savings start after this work already exists.", 22, MUTED) - story.append(frame("Do the investigation once.", - "A real Clang source investigation becomes reusable evidence.", body, 1)) - - body = box(64, 237, 1152, 106, INK, "none") - body += text(90, 274, "CHAT 2 · A SEPARATE CHAT", 18, "#b5d0ae", 700) - body += text(90, 317, "Which target and language options change the result?", 30, "#fffdf8", 600) - body += box(64, 363, 1152, 218, PALE) - body += text(90, 407, "PRIOR WORK DELIVERED AT THE START", 18, GREEN, 700) - body += lines(90, 455, ["Target triple → toolchain selection and caching", - "Per-toolchain arguments → TranslateXarchArgs"], 28, GREEN, 46) - body += text(90, 552, "The answer explicitly reports these findings as reused.", 22, MUTED) - body += text(64, 632, "The selected handoff contains the earlier answer and tool evidence.", 23) - body += text(64, 671, "The graph and retrieval run locally; selected context still uses model tokens.", 21, MUTED) - story.append(frame("Start from the findings.", - "Same project and source snapshot. Independent chat. Related question.", body, 2)) - - body = box(64, 237, 1152, 184) - body += text(90, 278, "NEW INVESTIGATION", 18, GREEN, 700) - body += lines(90, 323, ["Check language flags and target-specific test cases.", - "Identify a possible mismatch: -x order vs. input type."], 28, step=43) - body += text(90, 396, "Examples include SPIR-V, Darwin argument forwarding, and clang-cl inputs.", 21, MUTED) - body += box(64, 440, 1152, 140, "#f6e8da", "#e7cbb4") - body += text(90, 481, "LEFT OPEN", 18, AMBER, 700) - body += lines(90, 523, ["No built Clang / FileCheck: runtime behavior remains unverified.", - "No patch applied to the fixed source snapshot."], 24, step=35) - body += text(64, 635, f"Illustrated run: trial 1 · {data['completed_trials'][0]['total_tokens']:,} total tokens", 23, INK, 600) - body += text(64, 675, "Review: usable, based on source evidence. Dynamic confirmation remains open.", 21, MUTED) - story.append(frame("Carry the work forward.", - "English summary of the first completed answer: reused, new, and unverified.", body, 3)) - - body = box(64, 237, 712, 359) - body += text(90, 278, "MODEL INPUT + OUTPUT TOKENS", 18, MUTED, 700) - body += text(90, 329, "Fresh · one reference run", 24) - body += text(748, 329, f"{fresh:,}", 25, INK, 700, "end") - body += box(90, 350, 658, 34, "#8b9184", "none", 6) - body += text(90, 432, "Eggshell · per completion", 24) - body += text(748, 432, f"{inclusive:,.0f}", 25, GREEN, 700, "end") - body += box(90, 453, round(658 * inclusive / fresh, 2), 34, GREEN, "none", 6) - body += lines(90, 538, ["Includes both failed attempts.", - "All 12 attempts ÷ 10 completions."], 21, MUTED, 31) - body += box(796, 237, 420, 359, PALE) - body += text(824, 329, f"{reduction:.0f}%", 80, GREEN, 700) - body += text(824, 371, "fewer follow-up tokens", 24, GREEN, 600) - body += text(824, 426, "ANSWER REVIEW · 10 TRIALS", 17, MUTED, 700) - body += lines(824, 472, [f"{counts['usable']} usable", - f"{counts['usable_with_minor_corrections']} minor corrections", - f"{counts['needs_material_correction']} substantive correction"], 23, step=38) - body += lines(64, 636, ["One task; one fresh reference; prior investigation excluded.", - "Source-based, non-blinded review. General savings and quality parity unproven."], 21, MUTED, 36) - story.append(frame("See the cost and the quality.", - "The comparison includes every attempt, not only the successful runs.", body, 4, - "One fresh reference used 5,355,282 tokens. All 12 Eggshell attempts divided by " - "10 completions used 962,207 tokens per completion: 82% fewer. Six answers " - "were usable, three needed minor corrections, and one needed a substantive correction. " - "Prior investigation cost is excluded. This is a historical single-task study.")) - - # A still overview is available for reduced motion and link previews. - body = "" - cards = [ - (64, "1 · INVESTIGATE", ["Map the Clang", "driver and save", "work with evidence."]), - (460, "2 · NEW CHAT", ["Reuse target and", "argument findings", "from the first chat."]), - (856, "3 · CONTINUE", ["Check new edge cases.", "Keep unverified", "runtime tests open."]), - ] - for x, label, detail in cards: - body += box(x, 237, 360, 209, PALE if x == 460 else "#fffdf8") - body += text(x + 24, 281, label, 18, GREEN, 700) - body += lines(x + 24, 329, detail, 25, step=37) - body += box(64, 472, 1152, 153, INK, "none") - body += text(92, 536, f"{reduction:.0f}% fewer tokens", 42, "#e1efd7", 700) - body += text(92, 586, "Follow-up cost, including failed attempts", 21, "#e1efd7") - body += text(685, 517, "10 completed answer reviews", 22, "#e1efd7", 600) - body += lines(685, 558, [f"{counts['usable']} usable · {counts['usable_with_minor_corrections']} minor corrections", - f"{counts['needs_material_correction']} substantive correction"], 23, "#fffdf8", 36) - body += text(64, 671, "One task. One fresh reference. Prior work excluded. Watch the 30-second walkthrough.", 21, MUTED) - poster = frame("A new chat. A head start.", - "Recorded LLVM study · local memory organization without LLM calls", body, - description="A static overview of the recorded four-step Eggshell walkthrough. " - "Investigate in one chat, reuse findings in another, and check unresolved cases. " - "82% fewer follow-up tokens including failed attempts, with disclosed quality limits.") - - DEST.mkdir(parents=True, exist_ok=True) - names = ["01-investigate", "02-reuse", "03-continue", "04-results"] - for name, svg in zip(names, story): - (DEST / f"{name}.svg").write_text(svg) - (DEST / "overview.svg").write_text(poster) - with tempfile.TemporaryDirectory(prefix="eggshell-demo-") as temp: - temp = Path(temp) - pngs = [] - for name in names: - png = temp / f"{name}.png" - run("rsvg-convert", str(DEST / f"{name}.svg"), "-o", str(png)) - pngs.append(png) - run("magick", "-delay", "750", *map(str, pngs), "-loop", "0", - "-layers", "Optimize", str(DEST / "walkthrough.gif")) - concat = temp / "frames.txt" - concat.write_text("".join(f"file '{p}'\nduration 7.5\n" for p in pngs) - + f"file '{pngs[-1]}'\n") - run("ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "concat", - "-safe", "0", "-i", str(concat), "-t", "30", "-r", "24", - "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p", - "-movflags", "+faststart", str(DEST / "walkthrough.mp4")) - print("Rendered four scenes, a static overview, GIF, and 30-second MP4.") - print(f"Verified {len(data['completed_trials'])} completions, " - f"{len(data['failed_attempts'])} failures, and {reduction:.1f}% inclusive reduction.") - - -if __name__ == "__main__": - render() diff --git a/scripts/render_social_preview.py b/scripts/render_social_preview.py deleted file mode 100644 index ab5b825..0000000 --- a/scripts/render_social_preview.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -"""Export light/dark social cards while leaving the README hero unchanged.""" -import copy -from pathlib import Path -import subprocess -import xml.etree.ElementTree as ET - -DEST = Path(__file__).resolve().parents[1] / 'docs/assets/brand' -NS = '{http://www.w3.org/2000/svg}' -ET.register_namespace('', NS[1:-1]) - - -def render(theme, artwork, background, foreground): - logo = copy.deepcopy(ET.parse(DEST / artwork).getroot()) - # Enlarge the original lockup while keeping its center and caption fixed. - logo_width = 1060 - logo_height = logo_width * 360 / 938.119 - original_center_y = 96 + (920 * 360 / 938.119) / 2 - logo.attrib.update(x=str((1280 - logo_width) / 2), - y=str(original_center_y - logo_height / 2), - width=str(logo_width), height=str(logo_height)) - logo.attrib.pop('aria-labelledby', None) - for child in list(logo): - if child.tag in (NS + 'title', NS + 'desc'): - logo.remove(child) - svg = ('\n' - ' Eggshell — AI memory. Fewer tokens.\n' - ' Eggshell mascot and wordmark above the caption: ' - 'AI memory. Fewer tokens.\n' - f' \n' - + ET.tostring(logo, encoding='unicode') + '\n' - f' ' - 'AI memory. Fewer tokens.\n\n') - path = DEST / f'github-social-preview-{theme}-1280x640.svg' - path.write_text(svg) - subprocess.run(['rsvg-convert', str(path), '-o', str(path.with_suffix('.png'))], check=True) - - -if __name__ == '__main__': - render('dark', 'eggshell-primary-horizontal-white.svg', '#111111', '#f7f3ea') - render('light', 'eggshell-primary-horizontal.svg', '#f7f3ea', '#111111') diff --git a/tests/LifecycleTests.lean b/tests/LifecycleTests.lean new file mode 100644 index 0000000..86097f0 --- /dev/null +++ b/tests/LifecycleTests.lean @@ -0,0 +1,329 @@ +module + +public import Eggshell.Daemon + +@[expose] public section + +open Lean Eggshell Eggshell.Plugin + +def require (condition : Bool) (message : String) : IO Unit := + unless condition do throw (IO.userError message) + +def awaitCondition (condition : IO Bool) (label : String) : IO Unit := do + for _ in [0:400] do + if ← condition then return + IO.sleep 25 + throw (IO.userError ("condition did not complete: " ++ label)) + +def readObject (path : System.FilePath) : IO Json := do + IO.ofExcept (Json.parse (← IO.FS.readFile path)) + +structure Fixture where + root : System.FilePath + binary : System.FilePath + helper : System.FilePath + +def Fixture.files (f : Fixture) (session := "chat") := f.root / "data" / "sessions" / session +def Fixture.config (f : Fixture) := f.root / "global.toml" +def Fixture.env (f : Fixture) : Array (String × Option String) := #[ + ("EGGSHELL_DATA_ROOT", some (f.root / "data").toString), + ("EGGSHELL_CONFIG", some f.config.toString), + ("EGGSHELL_PREFIX", some (f.root / "runtime").toString), ("PLUGIN_ROOT", none), ("CODEX_THREAD_ID", none)] + +def Fixture.input (f : Fixture) (event : String) (session := "chat") (extra : List (String × Json) := []) := + Json.mkObj ([("hook_event_name", .str event), ("session_id", .str session), + ("turn_id", .str "turn"), ("cwd", .str f.root.toString)] ++ extra) + +def Fixture.hook (f : Fixture) (event : String) (session := "chat") (extra : List (String × Json) := []) : IO Json := do + let result ← IO.Process.output { + cmd := f.binary.toString + args := #["codex-hook"] + cwd := some f.root + env := f.env } (some (f.input event session extra).compress) + require (result.exitCode == 0) result.stderr + IO.ofExcept (Json.parse result.stdout) + +def Fixture.start (f : Fixture) (session := "chat") : IO Unit := do + let _ ← f.hook "SessionStart" session + let _ ← f.hook "UserPromptSubmit" session [("prompt", .str "Inspect the clock implementation")] + +def Fixture.post (f : Fixture) (marker : String) (session := "chat") (id := "probe") : IO Unit := do + let _ ← f.hook "PostToolUse" session [("tool_name", .str "shell"), ("tool_use_id", .str id), + ("tool_input", Json.mkObj [("command", .str "cat clock.c")]), + ("tool_response", Json.mkObj [("output", .str marker)])] + +def Fixture.egg (f : Fixture) : IO String := do + if ← (f.root / "work.egg").pathExists then IO.FS.readFile (f.root / "work.egg") else pure "" + +def Fixture.contains (f : Fixture) (marker : String) : IO Bool := do + pure (((← f.egg).splitOn marker).length > 1) + +def Fixture.saved (f : Fixture) (marker : String) : IO Unit := awaitCondition (f.contains marker) marker + +def Fixture.queue (f : Fixture) (session := "chat") : IO (Array System.FilePath) := do + let root := f.files session / "checkpoints" + if !(← root.isDir) then return #[] + return (← root.readDir).filterMap fun entry => if entry.fileName.endsWith ".json" then some entry.path else none + +def Fixture.endpoint (f : Fixture) (session := "chat") : IO Daemon.Endpoint := do + IO.ofExcept (fromJson? (← readObject (f.files session / "daemon.json"))) + +def Fixture.control (f : Fixture) (command : String) : IO UInt32 := do + let result ← IO.Process.output { + cmd := f.binary.toString + args := #["egg", command] + cwd := some f.root + env := f.env.push ("CODEX_THREAD_ID", some "chat") } + pure result.exitCode + +def locked (path : System.FilePath) (action : IO α) : IO α := do + let lock ← IO.FS.Handle.mk path .append + lock.lock + try action finally lock.unlock + +def killPid (pid : Nat) : IO Unit := do + let _ ← IO.Process.output { cmd := "/bin/kill", args := #["-KILL", toString pid] } + +def firstTests (f : Fixture) : IO Unit := do + f.start + f.post "EARLY_RESULT" + f.saved "EARLY_RESULT" + let pending ← readObject (f.files / "pending.json") + require (pending.getObjValD "finalMessage" == .null) "partial work became a final answer" + f.start "reader" + let reply ← f.hook "PreToolUse" "reader" [("tool_name", .str "shell"), ("tool_use_id", .str "reuse"), + ("tool_input", Json.mkObj [("command", .str "cat clock.c")])] + require ((reply.compress.splitOn "permissionDecision").length > 1) "partial work not reusable" + let before ← f.egg + f.post "EARLY_RESULT" + awaitCondition ((·.isEmpty) <$> f.queue) "duplicate queue drain" + require ((← f.egg) == before) "replay changed saved graph" + for name in ["work.egg.tmp", "work.egg.tmp-999999-0"] do IO.FS.writeFile (f.root / name) "{incomplete" + f.post "AFTER_ABANDONED_TEMP" "chat" "after-temp" + f.saved "AFTER_ABANDONED_TEMP" + require (← f.contains "EARLY_RESULT") "earlier outcome disappeared" + let _ ← IO.ofExcept (Json.parse (← f.egg)) + for name in ["work.egg.tmp", "work.egg.tmp-999999-0"] do + require ((← IO.FS.readFile (f.root / name)) == "{incomplete") "abandoned evidence overwritten" + IO.println "Core lifecycle: partial save, cross-chat reuse, replay and abandoned writes passed" + +def contentionTests (f : Fixture) : IO Unit := do + f.start + locked (f.root / "work.egg.guard") do + f.post "AFTER_AUTHORITY_LOCK" + awaitCondition ((!·.isEmpty) <$> f.queue) "checkpoint while authority locked" + let start ← IO.monoMsNow + let _ ← f.hook "Stop" + require ((← IO.monoMsNow) - start < 2500) "Stop waited for the authority lock" + IO.sleep 1300 + require (!(← f.queue).isEmpty && !(← f.contains "AFTER_AUTHORITY_LOCK")) "busy-authority receipt lost" + f.saved "AFTER_AUTHORITY_LOCK" + awaitCondition ((·.isEmpty) <$> f.queue) "authority queue drain" + f.start "journal" + locked (f.files "journal" / "save.guard") do + f.post "RECOVERED_JOURNAL" "journal" + for path in ← f.queue "journal" do IO.FS.removeFile path + require (!(← f.contains "RECOVERED_JOURNAL")) "save fixture failed to stop consumer" + f.saved "RECOVERED_JOURNAL" + IO.println "Core lifecycle: authority contention and journal recovery passed" + +def crashTests (f : Fixture) : IO Unit := do + f.start + let marker := f.root / "lock-held" + let owner ← IO.Process.spawn { + cmd := f.helper.toString + args := #["hold-lock", (f.root / "work.egg.guard").toString, marker.toString] + setsid := true } + try + awaitCondition marker.pathExists "lock-owner startup" + f.post "AFTER_LOCK_OWNER_CRASH" + killPid owner.pid.toNat + f.saved "AFTER_LOCK_OWNER_CRASH" + finally + try owner.kill catch _ => pure () + let _ ← owner.wait + pure () + f.start "restart" + locked (f.root / "work.egg.guard") do + f.post "AFTER_MANAGER_CRASH" "restart" + let before ← f.endpoint "restart" + killPid before.pid + let _ ← f.hook "SessionStart" "restart" + require ((← f.endpoint "restart").secret != before.secret) "manager was not replaced" + f.saved "AFTER_MANAGER_CRASH" + f.start "writer" + locked (f.root / "work.egg.guard") do + f.post "AFTER_WRITER_CRASH" "writer" + let manager := (← f.endpoint "writer").pid + let writer ← IO.mkRef (none : Option Nat) + awaitCondition (do + let listing ← IO.Process.run { cmd := "ps", args := #["-axo", "pid,ppid,args"] } + for line in listing.splitOn "\n" do + let parts := line.splitOn " " |>.filter (!·.isEmpty) + if parts[1]? == some (toString manager) && (line.splitOn "codex-worker save").length > 1 then + writer.set (parts.head?.bind String.toNat?) + return (← writer.get).isSome) "save worker startup" + killPid (← writer.get).get! + require (!(← f.queue "writer").isEmpty) "writer crash lost checkpoint" + f.saved "AFTER_WRITER_CRASH" + IO.println "Core lifecycle: killed lock owner, manager and save worker recovered" + +def isolationTests (f : Fixture) : IO Unit := do + let tasks ← (List.range 4).mapM fun _ => IO.asTask (f.hook "SessionStart") .dedicated + for task in tasks do let _ ← IO.ofExcept (← IO.wait task); pure () + f.start "second" + let first ← f.endpoint + let second ← f.endpoint "second" + require (first.port != second.port && first.secret != second.secret) "chats share manager identity" + let rejected ← try + let _ ← Daemon.exchange first "hook" (f.input "PostCompact" "second") + pure false + catch _ => pure true + require rejected "manager accepted another chat's event" + let duplicate ← IO.Process.output { cmd := f.binary.toString, args := #["codex-daemon", "chat"], env := f.env } + require (duplicate.exitCode != 0) "duplicate manager acquired the lease" + IO.FS.writeFile (f.files / "state.json") "tr" + IO.FS.writeFile (f.files / "pending.json") "tr" + let old ← IO.FS.readFile f.config + IO.FS.writeFile f.config "invalid" + require ((← f.control "off") == 0) "off could not recover broken state" + require ((← readObject (f.files / "state.json")).getObjValD "enabled" == .bool false) "off did not disable" + let reply ← f.hook "PostCompact" + require (reply == Json.mkObj []) "disabled compaction emitted instructions" + IO.FS.writeFile f.config (old.replace "work\"" "research\"" |>.replace "profiles.work" "profiles.research") + require ((← f.control "on") == 0) "on did not resolve repaired configuration" + IO.println "Core lifecycle: manager isolation, duplicate lease and corrupt-state control passed" + +def deliveryTests (f : Fixture) : IO Unit := do + f.start "seed" + f.post "FIRST_CLOCK_OBSERVATION" "seed" + f.saved "FIRST_CLOCK_OBSERVATION" + f.start "reader" + let fields := [("tool_name", .str "shell"), ("tool_use_id", .str "first"), + ("tool_input", Json.mkObj [("command", .str "cat clock.c")])] + let first ← f.hook "PreToolUse" "reader" fields + require ((first.getObjValD "hookSpecificOutput").getObjValD "permissionDecision" == .str "deny") "first outcome not reused" + f.start "second-seed" + f.post "SECOND_CLOCK_OBSERVATION" "second-seed" + f.saved "SECOND_CLOCK_OBSERVATION" + let fields := fields.map fun (k,v) => (k, if k == "tool_use_id" then .str "second" else v) + let second ← f.hook "PreToolUse" "reader" fields + require ((second.compress.splitOn "SECOND_CLOCK_OBSERVATION").length > 1) "new evidence ignored after prior denial" + let again ← f.hook "PreToolUse" "reader" (fields.map fun (k,v) => (k, if k == "tool_use_id" then .str "third" else v)) + require (!((again.getObjValD "hookSpecificOutput").getObjVal? "permissionDecision").isOk) + "unchanged evidence caused repeated denial" + f.start "lost-receipt" + let _ ← f.hook "PostCompact" "lost-receipt" + let endpoint ← f.endpoint "lost-receipt" + let _ ← Daemon.exchange endpoint "hook" (f.input "PreToolUse" "lost-receipt" (fields ++ [("_eggshell_receipt", .str "lost")])) + let delivered := do + let state ← readObject (f.files "lost-receipt" / "state.json") + let graphs ← IO.ofExcept (state.getObjValAs? (List String) "deliveredGraphs") + pure (graphs.any (·.startsWith "g:")) + require (!(← delivered)) "lost receipt marked graph delivered" + let _ ← f.hook "PostCompact" "lost-receipt" + let _ ← Daemon.exchange endpoint "ack" (Json.mkObj [("receipt", .str "lost")]) + require (!(← delivered)) "old-context receipt was accepted" + IO.println "Core lifecycle: changed evidence, unchanged denial suppression and lost/stale receipts passed" + +def deadlineTest (f : Fixture) : IO Unit := do + f.start "seed" + f.post "DEADLINE_SEED" "seed" + let _ ← f.hook "Stop" "seed" [("last_assistant_message", .str "Clock result for retrieval")] + f.saved "Clock result for retrieval" + let _ ← f.hook "SessionStart" "deadline" + let marker := f.root / "expired-provider-pids" + let config := f.root / "expired.toml" + let command := toJson [f.helper.toString, "hung-provider", marker.toString] + IO.FS.writeFile config ((← IO.FS.readFile f.config).replace "semantic_matcher = false" ("semantic_matcher = " ++ command.compress)) + let endpoint ← f.endpoint "deadline" + let clock ← Daemon.exchange endpoint "ping" .null + let some peer := clock.toNat? | throw (IO.userError "invalid peer monotonic clock") + let start ← IO.monoMsNow + let output ← Daemon.exchange endpoint "hook" (f.input "UserPromptSubmit" "deadline" [ + ("prompt", .str "Recall the clock result"), ("_eggshell_config", .str config.toString), + ("_eggshell_deadline", toJson (peer + 800)), ("_eggshell_receipt", .str "expired")]) + require ((← IO.monoMsNow) - start < 2000) "expired search exceeded its transport deadline" + require (← marker.pathExists) "deadline fixture never started" + require ((← IO.ofExcept (Json.parse output)) == Json.mkObj []) "expired search published context" + f.post "SAVED_AFTER_DEADLINE" "deadline" + f.saved "SAVED_AFTER_DEADLINE" + let pids ← IO.ofExcept (fromJson? (← readObject marker) : Except String (List Nat)) + for pid in pids do + let status ← IO.Process.output { cmd := "ps", args := #["-o", "stat=", "-p", toString pid] } + require (status.stdout.trimAscii.isEmpty || status.stdout.trimAscii.toString.startsWith "Z") "expired provider survived" + IO.println "Core lifecycle: expired search was reaped and subsequent work saved" + +def hungSearchTest (f : Fixture) : IO Unit := do + f.start "seed" + f.post "SEARCH_SEED" "seed" + let _ ← f.hook "Stop" "seed" [("last_assistant_message", .str "Clock investigation completed")] + f.saved "Clock investigation completed" + let marker := f.root / "provider-pids" + let config := f.root / "slow.toml" + let command := toJson [f.helper.toString, "hung-provider", marker.toString] + IO.FS.writeFile config ((← IO.FS.readFile f.config).replace "semantic_matcher = false" ("semantic_matcher = " ++ command.compress)) + let task ← IO.asTask (IO.Process.output { + cmd := f.binary.toString + args := #["codex-hook"] + cwd := some f.root + env := f.env.push ("EGGSHELL_CONFIG", some config.toString) } + (some (f.input "UserPromptSubmit" "slow" [("prompt", .str "What did we find about the clock?")]).compress)) .dedicated + awaitCondition marker.pathExists "hung provider startup" + let pids ← IO.ofExcept (fromJson? (← readObject marker) : Except String (List Nat)) + try + f.post "SAVED_DURING_HUNG_SEARCH" "slow" + f.saved "SAVED_DURING_HUNG_SEARCH" + let start ← IO.monoMsNow + f.start "independent" + require ((← IO.monoMsNow) - start < 3000) "search blocked another chat" + let start ← IO.monoMsNow + let _ ← f.hook "Stop" "slow" + require ((← IO.monoMsNow) - start < 2500) "search blocked Stop" + let some output ← Worker.awaitUntil task ((← IO.monoMsNow) + 4000) | + throw (IO.userError "search owner failed to stop") + require (output.exitCode == 0 && (← IO.ofExcept (Json.parse output.stdout)) == Json.mkObj []) "cancelled search published" + for pid in pids do + awaitCondition (do + let result ← IO.Process.output { cmd := "ps", args := #["-o", "stat=", "-p", toString pid] } + let status := result.stdout.trimAscii.toString + return status.isEmpty || status.startsWith "Z") "provider process-group cleanup" + finally for pid in pids do killPid pid + IO.println "Core lifecycle: hung search preserves saves, independent chats, Stop and process cleanup" + +def withFixture (test : Fixture → IO Unit) : IO Unit := do + let root ← IO.FS.createTempDir + let root ← IO.FS.realPath root + let f : Fixture := ⟨root, ← IO.FS.realPath ".lake/build/bin/eggshell", ← IO.appPath⟩ + let config := "semantic_matcher = false\ndefault = \"work\"\n[eggs]\nproject = \"work.egg\"\n[profiles.work]\nread = [\"project\"]\nwrite = \"project\"\n" + IO.FS.writeFile f.config config + IO.FS.writeFile (root / ".eggshell.toml") (config.replace "semantic_matcher = false\n" "") + try test f + finally + let sessions := root / "data" / "sessions" + if ← sessions.isDir then + for entry in ← sessions.readDir do + try + let endpoint ← IO.ofExcept (fromJson? (← readObject (entry.path / "daemon.json")) : Except String Daemon.Endpoint) + let _ ← Daemon.exchange endpoint "shutdown" .null + catch _ => pure () + IO.FS.removeDirAll root + +def main (args : List String) : IO UInt32 := do + match args with + | ["hold-lock", path, marker] => + locked (.mk path) do + IO.FS.writeFile (.mk marker) "ready" + IO.sleep 60000 + pure 0 + | ["sleep"] => IO.sleep 60000 *> pure 0 + | ["hung-provider", marker] => + let _ ← (← IO.getStdin).getLine + let child ← IO.Process.spawn { cmd := (← IO.appPath).toString, args := #["sleep"] } + IO.FS.writeFile (.mk marker) (toJson [(← IO.Process.getPID).toNat, child.pid.toNat]).compress + IO.sleep 60000 + pure 0 + | [] => + for test in [firstTests, contentionTests, crashTests, isolationTests, deliveryTests, deadlineTest, hungSearchTest] do withFixture test + pure 0 + | _ => throw (IO.userError "invalid lifecycle fixture command") diff --git a/tests/SearchTests.lean b/tests/SearchTests.lean new file mode 100644 index 0000000..0a3bc53 --- /dev/null +++ b/tests/SearchTests.lean @@ -0,0 +1,33 @@ +module + +public import Eggshell.SearchProvider + +@[expose] public section + +open Lean Eggshell + +def main : IO UInt32 := do + let root ← IO.FS.createTempDir + let cases := (← IO.FS.readFile "tests/fixtures/search-golden.jsonl").splitOn "\n" |>.filter (!·.isEmpty) + let binary ← IO.FS.realPath ".lake/build/bin/eggshell" + let models := (MiniLM.layout (← Paths.installRoot) root).models + try + for mode in ["lexical", "semantic", "hybrid"] do + let selected ← cases.filterMapM fun line => do + let json ← IO.ofExcept (Json.parse line) + pure (if json.getObjValD "mode" == .str mode then some json else none) + let input := String.intercalate "\n" (selected.map (fun j => (j.getObjValD "request").compress)) ++ "\n" + let result ← IO.Process.output { + cmd := binary.toString + args := #["search-provider", "--mode", mode, "--cache", (root / mode).toString, + "--model-cache", models.toString] + env := #[("HF_HUB_OFFLINE", some "1")] } (some input) + if result.exitCode != 0 then throw (IO.userError result.stderr) + let outputs := result.stdout.splitOn "\n" |>.filter (!·.isEmpty) + if outputs.length != selected.length then throw (IO.userError "provider response count mismatch") + for (output, test) in outputs.zip selected do + let json ← IO.ofExcept (Json.parse output) + if json != test.getObjValD "expected" then throw (IO.userError s!"{mode}: changed selected candidates: {output}") + IO.println s!"{mode}: {selected.length} legacy-provider comparisons passed" + finally IO.FS.removeDirAll root + pure 0 diff --git a/tests/SetupPackageTests.lean b/tests/SetupPackageTests.lean new file mode 100644 index 0000000..e950f84 --- /dev/null +++ b/tests/SetupPackageTests.lean @@ -0,0 +1,159 @@ +module + +public import Eggshell.Setup +public import Eggshell.Sha256 + +@[expose] public section + +open Lean Eggshell + +def ensure (value : Bool) (message : String) : IO Unit := unless value do throw (IO.userError message) + +def bootstrapTests (repository temporary : System.FilePath) : IO Unit := do + let fixture := temporary / "bootstrap" + let runtime := fixture / "prefix" + let bin := fixture / "bin" + let archive := fixture / "source.tar.gz" + IO.FS.createDirAll (fixture / "scripts") + IO.FS.createDirAll (fixture / "runtime-pins") + IO.FS.createDirAll bin + IO.FS.writeFile (fixture / "scripts/setup.sh") (← IO.FS.readFile (repository / "plugins/eggshell/scripts/setup.sh")) + -- This native test executable stands in for curl; the bootstrap still runs + -- its real checksum, archive inspection and installation path, offline. + IO.FS.writeBinFile (bin / "curl") (← IO.FS.readBinFile (← IO.appPath)) + IO.setAccessRights (bin / "curl") { user := { read := true, write := true, execution := true } } + let platform := if System.Platform.isOSX then "macos" else "linux" + let machine := (← IO.Process.run { cmd := "uname", args := #["-m"] }).trimAscii.toString + let arch := if machine == "arm64" || machine == "aarch64" then "aarch64" else "x86_64" + let pin := fixture / "runtime-pins" / (platform ++ "-" ++ arch) + let env := #[("PATH", some (bin.toString ++ ":" ++ (← IO.getEnv "PATH").getD "")), + ("EGGSHELL_TEST_ARCHIVE", some archive.toString), ("EGGSHELL_PREFIX", some runtime.toString), + ("EGGSHELL_DATA_ROOT", some (fixture / "data").toString), ("PLUGIN_ROOT", none), + ("EGGSHELL_CONFIG", none), ("CODEX_THREAD_ID", none)] + let run := IO.Process.output { cmd := "sh", args := #[(fixture / "scripts/setup.sh").toString, + "--project", fixture.toString], env } + let stage := fixture / "stage" + IO.FS.createDirAll stage + IO.FS.writeFile (stage / "eggshell") "must never execute" + let _ ← IO.Process.run { cmd := "tar", args := #["-czf", archive.toString, "-C", stage.toString, "eggshell"] } + IO.FS.writeFile pin ("v0.1.0\neggshell.tar.gz\n" ++ String.ofList (List.replicate 64 '0') ++ "\n") + let rejected ← run + ensure (rejected.exitCode != 0 && rejected.stderr.contains "checksum mismatch" && !(← runtime.pathExists)) + "bootstrap installed a checksum-mismatched archive" + IO.FS.removeFile (stage / "eggshell") + let _ ← IO.Process.run { cmd := "ln", args := #["-s", "/unrelated-eggshell", (stage / "eggshell").toString] } + let _ ← IO.Process.run { cmd := "tar", args := #["-czf", archive.toString, "-C", stage.toString, "eggshell"] } + IO.FS.writeFile pin ("v0.1.0\neggshell.tar.gz\n" ++ Sha256.hex (← IO.FS.readBinFile archive) ++ "\n") + let rejected ← run + ensure (rejected.exitCode != 0 && rejected.stderr.contains "regular file" && !(← runtime.pathExists)) + "bootstrap accepted a symlink runtime" + IO.println "Bootstrap: checksum and non-regular archive rejection passed without a network request" + +def installationTests (executable temporary : System.FilePath) : IO Unit := do + let installRoot := temporary / "runtime prefix's" + let support := MiniLM.supportRoot installRoot + let numerical := support / MiniLM.runtimeVersion / "bin/python" + IO.FS.createDirAll numerical.parent.get! + IO.FS.writeFile numerical "unused numerical fixture" + IO.FS.writeFile (support / (MiniLM.runtimeVersion ++ ".model-ready")) MiniLM.model + let plugin := installRoot / "plugins/eggshell" + IO.FS.createDirAll plugin + IO.FS.writeFile (plugin / ".eggshell-owner") "o8vm/eggshell\n" + IO.FS.writeFile (plugin / "sentinel") "existing plugin" + IO.FS.writeFile (installRoot / "work.egg") "user-owned work" + let marketplace := installRoot / ".agents/plugins/marketplace.json" + IO.FS.createDirAll marketplace.parent.get! + IO.FS.writeFile marketplace "{\"keep\":\"unchanged\"}\n" + let env := #[("EGGSHELL_PREFIX", some installRoot.toString), ("EGGSHELL_DATA_ROOT", some (installRoot / "data").toString), + ("PLUGIN_ROOT", none), ("CODEX_THREAD_ID", none), ("EGGSHELL_CONFIG", none)] + let result ← IO.Process.output { cmd := executable.toString, args := #["install", "runtime"], env } + ensure (result.exitCode == 0) result.stderr + ensure ((← IO.FS.readFile (plugin / "sentinel")) == "existing plugin" && + (← IO.FS.readFile (installRoot / "work.egg")) == "user-owned work" && + (← IO.FS.readFile marketplace) == "{\"keep\":\"unchanged\"}\n") "runtime install changed plugin or memory" + let project := installRoot / "project" + IO.FS.createDirAll project + let initialized ← IO.Process.output { cmd := (installRoot / "bin/egg").toString, args := #["init"], env, cwd := some project } + ensure (initialized.exitCode == 0 && (← (project / ".eggshell.toml").pathExists)) "installed launcher failed" + IO.println "Runtime installation: plugin, marketplace and memory preserved; installed launcher passed" + +def runTests : IO UInt32 := do + let repository ← IO.currentDir + let executable ← IO.FS.realPath ".lake/build/bin/eggshell" + let package ← IO.FS.realPath ".lake/build/bin/eggshell_package" + let temporary ← IO.FS.createTempDir + let temporary ← IO.FS.realPath temporary + let environment := #[("EGGSHELL_PREFIX", some (temporary / "runtime").toString), + ("EGGSHELL_DATA_ROOT", some (temporary / "data").toString), ("EGGSHELL_CONFIG", none), + ("CODEX_THREAD_ID", none), ("PLUGIN_ROOT", none)] + try + bootstrapTests repository temporary + installationTests executable temporary + let project := temporary / "project" + IO.FS.createDirAll project + let run (checkOnly : Bool) := IO.Process.output { + cmd := executable.toString + args := #["setup", "--project", project.toString] ++ if checkOnly then #["--check"] else #[] + env := environment } + let missing ← run true + ensure (missing.exitCode == 1) "missing configuration not reported" + ensure (!(← (project / ".eggshell.toml").pathExists)) "check initialized a project" + let ready ← run false + ensure (ready.exitCode == 0) ready.stderr + let original ← IO.FS.readFile (project / ".eggshell.toml") + let again ← run false + ensure (again.exitCode == 0 && (← IO.FS.readFile (project / ".eggshell.toml")) == original) "existing config overwritten" + IO.FS.writeFile (project / ".eggshell.toml") "invalid configuration retained" + let invalid ← run false + ensure (invalid.exitCode != 0 && (← IO.FS.readFile (project / ".eggshell.toml")) == "invalid configuration retained") "invalid config replaced" + IO.FS.removeFile (project / ".eggshell.toml") + IO.FS.writeFile (temporary / ".eggshell.toml") original + let inherited ← run false + ensure (inherited.exitCode == 0 && !(← (project / ".eggshell.toml").pathExists)) "parent config shadowed" + let shell ← IO.Process.output { + cmd := "sh" + args := #[(repository / "plugins/eggshell/scripts/setup.sh").toString, "--check", "--project", project.toString] + env := environment } + ensure (shell.exitCode == 1 && !(← (temporary / "runtime").pathExists)) "bootstrap check modified installation" + IO.println "Native setup: missing, existing, invalid and parent configuration checks passed" + let runtimes := temporary / "runtimes" + let staged := temporary / "staged" + let output := temporary / "package" + IO.FS.createDirAll runtimes + IO.FS.createDirAll staged + IO.FS.writeFile (staged / "eggshell") "fixture-executable" + for target in ["linux-aarch64", "linux-x86_64", "macos-aarch64", "macos-x86_64"] do + let _ ← IO.Process.run { cmd := "tar", args := #["-czf", (runtimes / ("eggshell-" ++ target ++ ".tar.gz")).toString, + "-C", staged.toString, "eggshell"] } + let result ← IO.Process.output { + cmd := package.toString + args := #["--runtime-dir", runtimes.toString, "--output", output.toString, "--release", "v0.1.0"] } + ensure (result.exitCode == 0) result.stderr + let archive := output / "eggshell-codex-plugin.zip" + let checked ← IO.Process.output { cmd := "unzip", args := #["-t", archive.toString] } + ensure (checked.exitCode == 0) checked.stdout + let names ← IO.Process.run { cmd := "unzip", args := #["-Z1", archive.toString] } + ensure ((names.splitOn "runtime-pins/").length == 5) "missing pinned runtime bootstrap inputs" + let pin ← IO.Process.run { cmd := "unzip", args := #["-p", archive.toString, "runtime-pins/macos-aarch64"] } + let checksum := Sha256.hex (← IO.FS.readBinFile (runtimes / "eggshell-macos-aarch64.tar.gz")) + ensure ((pin.splitOn checksum).length > 1) "incorrect runtime checksum" + let first ← IO.FS.readBinFile archive + let repeated ← IO.Process.output { + cmd := package.toString + args := #["--runtime-dir", runtimes.toString, "--output", output.toString, "--release", "v0.1.0"] } + ensure (repeated.exitCode == 0 && (← IO.FS.readBinFile archive) == first) "package is not reproducible" + IO.println "Native package: independent ZIP reader, four runtime checksums and reproducibility passed" + finally IO.FS.removeDirAll temporary + pure 0 + +def main (args : List String) : IO UInt32 := do + if args.head? == some "--proto" then + let rec destination : List String → Option String + | "--output" :: path :: _ => some path + | _ :: rest => destination rest + | [] => none + let some output := destination args | throw (IO.userError "test curl missing output") + let some source ← IO.getEnv "EGGSHELL_TEST_ARCHIVE" | throw (IO.userError "test curl missing archive") + IO.FS.writeBinFile (.mk output) (← IO.FS.readBinFile (.mk source)) + pure 0 + else runTests diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..815638f --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,14 @@ +# Search migration fixture + +`search-golden.jsonl` contains 24 request/response comparisons: eight each for +lexical, semantic and hybrid retrieval. Expected selections were captured from +the embedded Python provider at commit `64d3737021c697eba4c9fa08a37924b1a9c6874f`, +using Python 3.14.6 (Unicode 16.0) and FastEmbed 0.8.0 with +`sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`. + +The fixture exercises changed text under reused IDs, Unicode, exact identifiers, +long records and retrieval limits. `tests/SearchTests.lean` sends the requests +through the native provider and checks every returned selection, using the +locally cached numerical runtime with model-network access disabled. This is a +selection regression test, not a token-reduction experiment or a proof of +floating-point equivalence. See `docs/lean-boundaries.md` for the proof scope. diff --git a/tests/fixtures/search-golden.jsonl b/tests/fixtures/search-golden.jsonl new file mode 100644 index 0000000..af1c3a2 --- /dev/null +++ b/tests/fixtures/search-golden.jsonl @@ -0,0 +1,24 @@ +{"mode": "lexical", "request": {"query": {"id": "q", "text": "CONFIG_ARCHIVE_SENTINEL_7E29"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [1]}} +{"mode": "lexical", "request": {"query": {"id": "q", "text": "STRASSE"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [2]}} +{"mode": "lexical", "request": {"query": {"id": "q", "text": "σ K FI"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [3]}} +{"mode": "lexical", "request": {"query": {"id": "q", "text": "日本語 記憶"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [4]}} +{"mode": "lexical", "request": {"query": {"id": "q", "text": "foo bar baz"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [5, 6]}} +{"mode": "lexical", "request": {"query": {"id": "q", "text": "nothing at all"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": []}} +{"mode": "lexical", "request": {"query": {"id": "q", "text": ""}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": []}} +{"mode": "lexical", "request": {"query": {"id": "q", "text": "changed bytes"}, "candidates": [{"id": "same", "text": "changed bytes"}, {"id": "same", "text": "unrelated"}]}, "expected": {"related": [0]}} +{"mode": "semantic", "request": {"query": {"id": "q", "text": "CONFIG_ARCHIVE_SENTINEL_7E29"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [1, 3]}} +{"mode": "semantic", "request": {"query": {"id": "q", "text": "STRASSE"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [7, 3, 5, 6]}} +{"mode": "semantic", "request": {"query": {"id": "q", "text": "σ K FI"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [3, 7, 6, 5]}} +{"mode": "semantic", "request": {"query": {"id": "q", "text": "日本語 記憶"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [4, 7, 3]}} +{"mode": "semantic", "request": {"query": {"id": "q", "text": "foo bar baz"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [5, 6, 7, 3]}} +{"mode": "semantic", "request": {"query": {"id": "q", "text": "nothing at all"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": []}} +{"mode": "semantic", "request": {"query": {"id": "q", "text": ""}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [7, 3, 5, 6]}} +{"mode": "semantic", "request": {"query": {"id": "q", "text": "changed bytes"}, "candidates": [{"id": "same", "text": "changed bytes"}, {"id": "same", "text": "unrelated"}]}, "expected": {"related": [0]}} +{"mode": "hybrid", "request": {"query": {"id": "q", "text": "CONFIG_ARCHIVE_SENTINEL_7E29"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [1, 3]}} +{"mode": "hybrid", "request": {"query": {"id": "q", "text": "STRASSE"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [2, 7, 3, 5, 6]}} +{"mode": "hybrid", "request": {"query": {"id": "q", "text": "σ K FI"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [3, 7, 6, 5]}} +{"mode": "hybrid", "request": {"query": {"id": "q", "text": "日本語 記憶"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [4, 7, 3]}} +{"mode": "hybrid", "request": {"query": {"id": "q", "text": "foo bar baz"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [5, 6, 7, 3]}} +{"mode": "hybrid", "request": {"query": {"id": "q", "text": "nothing at all"}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": []}} +{"mode": "hybrid", "request": {"query": {"id": "q", "text": ""}, "candidates": [{"id": "0", "text": "Network driver documentation and packet routing."}, {"id": "1", "text": "Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. Historical notes. \nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, {"id": "2", "text": "Straße STRASSE ss /usr/include/clock.h"}, {"id": "3", "text": "Σ σ ς K K İ fi FI"}, {"id": "4", "text": "日本語 メモリー 記憶 削減"}, {"id": "5", "text": "foo foo foo bar baz"}, {"id": "6", "text": "foo bar baz qux"}, {"id": "7", "text": ""}]}, "expected": {"related": [7, 3, 5, 6]}} +{"mode": "hybrid", "request": {"query": {"id": "q", "text": "changed bytes"}, "candidates": [{"id": "same", "text": "changed bytes"}, {"id": "same", "text": "unrelated"}]}, "expected": {"related": [0]}} diff --git a/tests/test_adapters.py b/tests/test_adapters.py deleted file mode 100644 index 0abfb0e..0000000 --- a/tests/test_adapters.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Independent adapters against the real unchanged Eggshell memory engine.""" -import concurrent.futures -import contextlib -import io -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest -from unittest import mock - -import test_hook_lifecycle as lifecycle - -ROOT = Path(__file__).resolve().parents[1] -ADAPTER = ROOT / 'adapters/eggshell_adapter.py' -BRIDGE = ROOT / 'adapters/native/.lake/build/bin/eggshell_bridge' -sys.path.insert(0, str(ROOT / 'adapters')) -import eggshell_adapter as adapter -import install as installer - - -class AdapterTests(unittest.TestCase): - def setUp(self): - self.fixture = lifecycle.LifecycleTests() - self.fixture.setUp() - self.root = self.fixture.root - self.env = self.fixture.env - self.env['EGGSHELL_PREFIX'] = str(self.root / 'prefix') - - def tearDown(self): - self.fixture.tearDown() - - def raw(self, client, event, session='chat', turn='turn', **fields): - native = next(key for key, value in adapter.EVENTS[client].items() if value == event) - raw = dict(hook_event_name=native, cwd=str(self.root), **fields) - if client == 'cursor': - raw.update(conversation_id=session, generation_id=turn, workspace_roots=[str(self.root)]) - else: - raw['session_id'] = session - if client == 'opencode': - raw['turn_id'] = turn - if client == 'gemini': - raw.pop('tool_use_id', None) - if 'last_assistant_message' in raw: - raw['prompt_response'] = raw.pop('last_assistant_message') - if client == 'cursor' and 'tool_response' in raw: - raw['tool_output'] = json.dumps(raw.pop('tool_response')) - return raw - - def send(self, client, raw): - result = subprocess.run([sys.executable, ADAPTER, 'hook', client, '--bridge', BRIDGE], - input=json.dumps(raw), text=True, capture_output=True, env=self.env, - cwd=self.root, timeout=35) - self.assertEqual(result.returncode, 0, result.stderr) - try: - parsed = json.loads(result.stdout) - except ValueError: - self.fail('hook did not emit exactly one JSON object: ' + result.stdout + result.stderr) - return parsed - - def hook(self, client, event, session='chat', turn='turn', **fields): - return self.send(client, self.raw(client, event, session, turn, **fields)) - - def start(self, client, session='chat', turn='turn'): - self.hook(client, 'SessionStart', session, turn, source='startup') - return self.hook(client, 'UserPromptSubmit', session, turn, - prompt='Inspect the clock implementation') - - def tool(self, client, marker, call='call', session='chat', turn='turn', command='cat clock.c'): - fields = dict(tool_name='shell', tool_use_id=call, tool_input={'command': command}) - self.hook(client, 'PreToolUse', session, turn, **fields) - return self.hook(client, 'PostToolUse', session, turn, - tool_response={'output': marker}, **fields) - - def state(self, client, session='chat'): - return json.loads((self.fixture.data / 'sessions' / - adapter.session_key(client, session) / 'state.json').read_text()) - - def test_each_adapter_saves_progress_before_stop_and_reuses_in_a_new_chat(self): - for client in adapter.CLIENTS: - with self.subTest(client=client): - session = client + '-writer' - self.start(client, session) - marker = 'partial_fact_from_' + client - command = 'cat ' + client + '-clock.c' - self.tool(client, marker, session=session, command=command) - lifecycle.wait_for(lambda: marker.encode() in self.fixture.egg()) - reader = client + '-reader' - self.start(client, reader) - result = self.hook(client, 'PreToolUse', reader, - tool_name='shell', tool_use_id='reader-probe', - tool_input={'command': command}) - reply = result.get('output', result) - self.assertTrue(adapter.is_denied(reply), result) - - def test_different_harnesses_do_not_share_a_chat_manager(self): - native = '../../same-native-id' - for client in adapter.CLIENTS: - self.start(client, native) - directories = [self.fixture.data / 'sessions' / adapter.session_key(client, native) - for client in adapter.CLIENTS] - endpoints = [json.loads((directory / 'daemon.json').read_text()) for directory in directories] - self.assertEqual(len({endpoint['pid'] for endpoint in endpoints}), 4) - self.assertEqual(len({endpoint['session'] for endpoint in endpoints}), 4) - - def test_unsupported_cursor_prompt_context_is_not_marked_delivered(self): - self.start('claude', 'writer') - self.tool('claude', 'a_saved_clock_result', session='writer') - self.hook('claude', 'Stop', 'writer', last_assistant_message='The clock is monotonic.') - lifecycle.wait_for(lambda: b'The clock is monotonic.' in self.fixture.egg()) - reply = self.start('cursor', 'reader') - self.assertEqual(reply, {}) - self.assertEqual(self.state('cursor', 'reader')['lastHandoff'], '') - - def test_opencode_acknowledges_only_after_host_insertion_and_rejects_stale_ack(self): - self.start('claude', 'writer') - self.tool('claude', 'acknowledgement_marker', session='writer') - lifecycle.wait_for(lambda: b'acknowledgement_marker' in self.fixture.egg()) - self.start('opencode', 'reader') - reply = self.hook('opencode', 'PreToolUse', 'reader', tool_name='shell', - tool_use_id='reuse', tool_input={'command': 'cat clock.c'}) - self.assertTrue(adapter.is_denied(reply['output']), reply) - self.assertEqual(self.state('opencode', 'reader')['lastHandoff'], '') - self.hook('opencode', 'PostCompact', 'reader') - ack = {key: reply[key] for key in ('session_id', 'receipt')} - result = subprocess.run([sys.executable, ADAPTER, 'ack', 'opencode', '--bridge', BRIDGE], - input=json.dumps(ack), text=True, capture_output=True, env=self.env, cwd=self.root) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(self.state('opencode', 'reader')['lastHandoff'], '') - - def test_cursor_answer_is_final_only_when_the_loop_completes(self): - self.start('cursor') - self.tool('cursor', 'cursor_partial_marker') - self.hook('cursor', 'AssistantMessage', text='A candidate answer before loop completion.') - self.assertNotIn(b'A candidate answer', self.fixture.egg()) - self.hook('cursor', 'Stop', status='aborted') - self.assertNotIn(b'A candidate answer', self.fixture.egg()) - self.start('cursor', turn='next') - self.hook('cursor', 'AssistantMessage', turn='next', text='The final verified clock answer.') - self.hook('cursor', 'Stop', turn='next', status='completed') - lifecycle.wait_for(lambda: b'The final verified clock answer.' in self.fixture.egg()) - - def test_parallel_identical_gemini_tools_keep_both_results(self): - self.start('gemini') - fields = dict(tool_name='run_shell_command', tool_input={'command': 'cat clock.c'}) - with concurrent.futures.ThreadPoolExecutor(2) as pool: - futures = [pool.submit(self.hook, 'gemini', 'PreToolUse', **fields) for _ in range(2)] - for future in futures: - future.result() - with concurrent.futures.ThreadPoolExecutor(2) as pool: - futures = [pool.submit(self.hook, 'gemini', 'PostToolUse', - tool_response={'llmContent': marker}, **fields) - for marker in ('gemini_parallel_one', 'gemini_parallel_two')] - for future in futures: - future.result() - lifecycle.wait_for(lambda: b'gemini_parallel_one' in self.fixture.egg() and - b'gemini_parallel_two' in self.fixture.egg()) - - def test_failed_tools_are_recorded_as_errors(self): - self.start('claude') - self.hook('claude', 'PreToolUse', tool_name='Bash', tool_use_id='failed', - tool_input={'command': 'missing-command'}) - raw = self.raw('claude', 'PostToolUse', tool_name='Bash', tool_use_id='failed', - tool_input={'command': 'missing-command'}, error='command not found') - raw['hook_event_name'] = 'PostToolUseFailure' - self.send('claude', raw) - lifecycle.wait_for(lambda: b'command not found' in self.fixture.egg()) - self.assertIn(b'is_error', self.fixture.egg()) - - def test_late_tool_result_is_saved_under_its_original_turn(self): - self.start('claude') - fields = dict(tool_name='Bash', tool_use_id='late', tool_input={'command': 'cat late.c'}) - self.hook('claude', 'PreToolUse', **fields) - self.hook('claude', 'Stop', last_assistant_message='The earlier task is closed.') - lifecycle.wait_for(lambda: b'The earlier task is closed.' in self.fixture.egg()) - self.hook('claude', 'UserPromptSubmit', prompt='A different subsequent task') - self.hook('claude', 'PostToolUse', tool_response={'output': 'LATE_ORIGINAL_RESULT'}, **fields) - lifecycle.wait_for(lambda: b'LATE_ORIGINAL_RESULT' in self.fixture.egg()) - - def test_off_does_not_save_cursor_answer_or_tool_results(self): - self.start('cursor') - session = adapter.session_key('cursor', 'chat') - subprocess.run([BRIDGE, 'egg', 'off'], env=dict(self.env, CODEX_THREAD_ID=session), - cwd=self.root, capture_output=True, check=True) - self.hook('cursor', 'AssistantMessage', text='PRIVATE_ANSWER_SHOULD_NOT_BE_SAVED') - self.tool('cursor', 'PRIVATE_TOOL_SHOULD_NOT_BE_SAVED') - self.hook('cursor', 'Stop', status='completed') - self.assertNotIn(b'PRIVATE_', self.fixture.egg()) - self.assertFalse(list(self.fixture.data.glob('sessions/*/adapter-drafts/*.json'))) - - def test_one_turn_private_profile_does_not_store_a_cursor_draft(self): - self.fixture.config.write_text(self.fixture.config.read_text() + - '\n[profiles.private]\nread = ["project"]\n') - self.hook('cursor', 'SessionStart', source='startup') - env = dict(self.env, CODEX_THREAD_ID=adapter.session_key('cursor', 'chat')) - subprocess.run([BRIDGE, 'egg', 'next', 'private'], env=env, cwd=self.root, - capture_output=True, check=True) - self.hook('cursor', 'UserPromptSubmit', prompt='A private investigation') - self.hook('cursor', 'AssistantMessage', text='PRIVATE_TURN_ANSWER') - self.assertFalse(list(self.fixture.data.glob('sessions/*/adapter-drafts/*.json'))) - self.hook('cursor', 'Stop', status='completed') - self.assertNotIn(b'PRIVATE_TURN_ANSWER', self.fixture.egg()) - - def test_gemini_ambiguous_results_are_retained_without_a_false_parent(self): - self.start('gemini') - fields = dict(tool_name='run_shell_command', tool_input={'command': 'cat overlap.c'}) - self.hook('gemini', 'PreToolUse', **fields) - self.hook('gemini', 'UserPromptSubmit', prompt='A different overlapping turn') - self.hook('gemini', 'PreToolUse', **fields) - self.hook('gemini', 'PostToolUse', tool_response={'llmContent': 'AMBIGUOUS_RESULT'}, **fields) - files = list((self.fixture.data / 'adapters/unattributed').glob('*/*.json')) - self.assertEqual(len(files), 1) - receipt = json.loads(files[0].read_text()) - self.assertNotIn('turn_id', receipt) - self.assertEqual(receipt['tool_response']['llmContent'], 'AMBIGUOUS_RESULT') - self.assertNotIn(b'AMBIGUOUS_RESULT', self.fixture.egg()) - - def test_gemini_replayed_timestamp_keeps_original_tool_identity(self): - self.start('gemini') - fields = dict(tool_name='run_shell_command', tool_input={'command': 'cat replay.c'}) - self.hook('gemini', 'PreToolUse', timestamp='2026-09-12T00:00:00.000Z', **fields) - raw = self.raw('gemini', 'PostToolUse', timestamp='2026-09-12T00:00:01.000Z', - tool_response={'llmContent': 'REPLAYED_RESULT'}, **fields) - self.send('gemini', raw) - lifecycle.wait_for(lambda: b'REPLAYED_RESULT' in self.fixture.egg()) - before = self.fixture.egg() - self.send('gemini', raw) - self.assertEqual(self.fixture.egg(), before) - - def test_ack_failure_does_not_append_a_second_json_response(self): - raw = self.raw('claude', 'UserPromptSubmit', prompt='A question') - output = io.StringIO() - receipt = dict(ok=True, output={'hookSpecificOutput': {'additionalContext': 'Prior work'}}, - receipt='receipt') - with mock.patch.object(adapter, 'invoke', side_effect=[receipt, RuntimeError('ack unavailable')]), \ - contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()): - adapter.run_hook('claude', raw, BRIDGE, self.root / 'adapter-state') - self.assertEqual(json.loads(output.getvalue())['hookSpecificOutput']['additionalContext'], 'Prior work') - - def test_stop_outputs_never_request_an_agent_retry(self): - malicious = {'continue': True, 'followup_message': 'repeat forever', - 'decision': 'block', 'reason': 'repeat', - 'hookSpecificOutput': {'additionalContext': 'repeat', - 'permissionDecision': 'deny', 'permissionDecisionReason': 'repeat'}} - for client in adapter.CLIENTS: - for event in ('Stop', 'Interrupt', 'SessionEnd'): - self.assertEqual(adapter.translate(client, event, malicious), {}) - - def test_installation_preserves_other_hooks_and_uninstall_removes_only_ours(self): - prefix = self.root / "adapter prefix's" - for client in adapter.CLIENTS: - with self.subTest(client=client): - project = self.root / client - project.mkdir() - config = project / installer.CONFIGS[client] - if client != 'opencode': - config.parent.mkdir(parents=True) - initial = {'otherSetting': True, 'hooks': {'SomeOtherEvent': [{'command': 'keep-me'}]}} - if client == 'cursor': - initial['version'] = 1 - config.write_text(json.dumps(initial)) - installer.install(client, project, prefix, BRIDGE) - first = config.read_text() - installer.install(client, project, prefix, BRIDGE) - self.assertEqual(config.read_text(), first) - installer.install(client, project, prefix, BRIDGE, uninstall=True) - if client == 'opencode': - self.assertFalse(config.exists()) - else: - self.assertEqual(json.loads(config.read_text()), initial) - self.assertTrue((prefix / 'share/eggshell-adapters/eggshell-bridge').exists()) - - def test_invalid_configuration_and_unowned_plugin_are_not_overwritten(self): - for client in ('claude', 'opencode'): - project = self.root / ('invalid-' + client) - config = project / installer.CONFIGS[client] - config.parent.mkdir(parents=True) - config.write_text('preserve this unowned content') - with self.assertRaises(ValueError): - installer.install(client, project, self.root / 'prefix', BRIDGE) - self.assertEqual(config.read_text(), 'preserve this unowned content') - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_hook_lifecycle.py b/tests/test_hook_lifecycle.py deleted file mode 100644 index 4f6e55b..0000000 --- a/tests/test_hook_lifecycle.py +++ /dev/null @@ -1,443 +0,0 @@ -"""Real-process lifecycle regressions; no LLM, network, or installed runtime. - -Each test owns its temporary data, managers, locks, and fake provider processes. -The production native-hook entrypoint is exercised, not a second implementation. -""" -import concurrent.futures -import fcntl -import json -import os -from pathlib import Path -import signal -import socket -import struct -import subprocess -import sys -import tempfile -import time -import unittest - -ROOT = Path(__file__).resolve().parents[1] -BIN = ROOT / '.lake/build/bin/eggshell' - - -def wait_for(predicate, seconds=8): - end = time.monotonic() + seconds - while time.monotonic() < end: - result = predicate() - if result: - return result - time.sleep(.025) - raise AssertionError('condition did not become true before deadline') - - -def rpc(endpoint, kind, payload=None): - request = json.dumps(dict(secret=endpoint['secret'], kind=kind, payload=payload)).encode() - with socket.create_connection(('127.0.0.1', endpoint['port']), timeout=4) as connection: - connection.settimeout(25) - connection.sendall(struct.pack('!I', len(request)) + request) - def read(size): - result = b'' - while len(result) < size: - chunk = connection.recv(size - len(result)) - if not chunk: - raise EOFError('incomplete daemon reply') - result += chunk - return result - return json.loads(read(struct.unpack('!I', read(4))[0])) - - -class LifecycleTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory(prefix='eggshell-lifecycle-') - self.root = Path(self.temp.name).resolve() - self.data = self.root / 'data' - self.config = self.root / 'global.toml' - self.config.write_text('semantic_matcher = false\n' - 'default = "work"\n[eggs]\nproject = "work.egg"\n' - '[profiles.work]\nread = ["project"]\nwrite = "project"\n') - (self.root / '.eggshell.toml').write_text( - 'default = "work"\n[eggs]\nproject = "work.egg"\n' - '[profiles.work]\nread = ["project"]\nwrite = "project"\n') - self.env = dict(os.environ, EGGSHELL_DATA_ROOT=str(self.data), - EGGSHELL_CONFIG=str(self.config)) - self.env.pop('PLUGIN_ROOT', None) - self.children = [] - - def tearDown(self): - for endpoint in self.data.glob('sessions/*/daemon.json'): - try: - rpc(json.loads(endpoint.read_text()), 'shutdown') - except (OSError, EOFError, ValueError): - pass - for child in self.children: - if child.poll() is None: - os.killpg(child.pid, signal.SIGKILL) - child.wait(timeout=3) - for stream in (child.stdin, child.stdout, child.stderr): - if stream and not stream.closed: - stream.close() - self.temp.cleanup() - - def input(self, event, session='chat', turn='turn', **extra): - return dict(hook_event_name=event, session_id=session, turn_id=turn, - cwd=str(self.root), **extra) - - def hook(self, event, session='chat', turn='turn', **extra): - start = time.monotonic() - result = subprocess.run([BIN, 'codex-hook'], input=json.dumps( - self.input(event, session, turn, **extra)), text=True, capture_output=True, - env=self.env, cwd=self.root, timeout=28) - self.assertEqual(result.returncode, 0, result.stderr) - return json.loads(result.stdout), time.monotonic() - start - - def start(self, session='chat', turn='turn'): - self.hook('SessionStart', session, turn) - self.hook('UserPromptSubmit', session, turn, prompt='Inspect the clock implementation') - - def post(self, use='probe', marker='observed_clock_fact', session='chat', turn='turn'): - return self.hook('PostToolUse', session, turn, tool_name='shell', tool_use_id=use, - tool_input={'command': 'cat clock.c'}, tool_response={'output': marker}) - - def state(self, session='chat'): - return json.loads((self.data / 'sessions' / session / 'state.json').read_text()) - - def endpoint(self, session='chat'): - return json.loads((self.data / 'sessions' / session / 'daemon.json').read_text()) - - def egg(self): - path = self.root / 'work.egg' - return path.read_bytes() if path.exists() else b'' - - def test_setup_status_reports_actual_hook_and_preserves_compaction(self): - started, _ = self.hook('SessionStart', source='startup') - self.assertIn('session hook connected', started['systemMessage']) - self.assertIn('memory read/write', started['systemMessage']) - self.assertNotIn('hookSpecificOutput', started) - env = dict(self.env, CODEX_THREAD_ID='chat') - before = self.state() - result = subprocess.run([BIN, 'egg', 'doctor'], env=env, cwd=self.root, - capture_output=True, text=True, check=True) - report = json.loads(result.stdout) - self.assertEqual(report['configuration'], 'ready') - self.assertTrue(report['session_state_present']) - self.assertFalse(report['handoff_observed']) - self.assertEqual(self.state(), before) - compacted, _ = self.hook('SessionStart', source='compact') - self.assertEqual(compacted, {}) - self.assertEqual(self.state()['epoch'], before['epoch'] + 1) - subprocess.run([BIN, 'egg', 'off'], env=env, cwd=self.root, - capture_output=True, check=True) - resumed, _ = self.hook('SessionStart', source='resume') - self.assertIn('memory is off', resumed['systemMessage']) - self.assertFalse(self.state()['enabled']) - - def test_missing_configuration_notice_and_readonly_doctor(self): - self.env.pop('EGGSHELL_CONFIG', None) - self.env['EGGSHELL_PREFIX'] = str(self.root / 'prefix') - (self.root / '.eggshell.toml').unlink() - doctor = subprocess.run([BIN, 'egg', 'doctor'], env=self.env, cwd=self.root, - text=True, capture_output=True, check=True) - self.assertEqual(json.loads(doctor.stdout)['configuration'], 'missing') - self.assertFalse(self.data.exists()) - message, _ = self.hook('SessionStart', source='startup') - self.assertIn('not configured', message['systemMessage']) - self.assertNotIn('hookSpecificOutput', message) - self.assertFalse(self.egg()) - state = self.data / 'sessions/chat/state.json' - state.write_text('{broken') - result = subprocess.run([BIN, 'egg', 'doctor'], cwd=self.root, - env=dict(self.env, CODEX_THREAD_ID='chat'), text=True, capture_output=True) - self.assertNotEqual(result.returncode, 0) - self.assertEqual(state.read_text(), '{broken') - self.assertFalse(list(state.parent.glob('*.corrupt-*'))) - - def test_partial_results_reach_egg_before_stop_and_replay_is_idempotent(self): - self.start() - # Existing installations have no lifecycle epoch/offers/closed fields. - for name, fields in [('state.json', ('epoch', 'offers')), ('pending.json', ('closed',))]: - path = self.data / 'sessions/chat' / name - old = json.loads(path.read_text()) - for field in fields: - old.pop(field, None) - path.write_text(json.dumps(old)) - self.post() - wait_for(lambda: b'observed_clock_fact' in self.egg()) - pending = json.loads((self.data / 'sessions/chat/pending.json').read_text()) - self.assertIsNone(pending['finalMessage']) - self.assertNotIn(b'interrupted before final response', self.egg()) - # An independent chat must be able to reuse the saved child Work even - # before the original chat produces any final answer. - self.start('partial-reader') - reused, _ = self.hook('PreToolUse', 'partial-reader', tool_name='shell', - tool_use_id='partial-reuse', tool_input={'command': 'cat clock.c'}) - self.assertIn('permissionDecision', json.dumps(reused)) - before = self.egg() - self.post() - wait_for(lambda: not list((self.data / 'sessions/chat/checkpoints').glob('*.json'))) - self.assertEqual(self.egg(), before) - - def test_authority_contention_retains_results_and_retries_without_a_new_turn(self): - self.start() - with open(self.root / 'work.egg.guard', 'a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - self.post(marker='retained_after_busy_authority') - queue = self.data / 'sessions/chat/checkpoints' - wait_for(lambda: list(queue.glob('*.json'))) - self.hook('PostCompact') - reused, _ = self.hook('PreToolUse', tool_name='shell', tool_use_id='reused-pending', - tool_input={'command': 'cat clock.c'}) - self.assertIn('retained_after_busy_authority', json.dumps(reused)) - _, elapsed = self.hook('Stop', last_assistant_message=None) - self.assertLess(elapsed, 2.5) - time.sleep(1.3) # force a failed authority-lock attempt - self.assertTrue(list(queue.glob('*.json'))) - self.assertNotIn(b'retained_after_busy_authority', self.egg()) - wait_for(lambda: b'retained_after_busy_authority' in self.egg()) - wait_for(lambda: not list(queue.glob('*.json'))) - pending = json.loads((self.data / 'sessions/chat/pending.json').read_text()) - self.assertTrue(pending['closed']) - self.assertIsNone(pending['finalMessage']) - - def test_killed_lock_owner_does_not_leave_a_permanent_lock(self): - self.start() - marker = self.root / 'locked' - child = subprocess.Popen([sys.executable, '-c', - 'import fcntl,time,pathlib,sys; f=open(sys.argv[1],"a"); ' - 'fcntl.flock(f,fcntl.LOCK_EX); pathlib.Path(sys.argv[2]).touch(); time.sleep(60)', - str(self.root / 'work.egg.guard'), str(marker)], start_new_session=True) - self.children.append(child) - wait_for(marker.exists) - self.post(marker='saved_after_lock_owner_died') - os.killpg(child.pid, signal.SIGKILL) - child.wait(timeout=3) - wait_for(lambda: b'saved_after_lock_owner_died' in self.egg()) - - def test_writer_crash_retries_durable_checkpoint_without_a_new_turn(self): - self.start() - with open(self.root / 'work.egg.guard', 'a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - self.post(marker='survives_writer_crash') - manager = self.endpoint()['pid'] - def writer_pid(): - listing = subprocess.run(['ps', '-axo', 'pid,ppid,args'], - capture_output=True, text=True, check=True).stdout - for line in listing.splitlines(): - fields = line.split(None, 2) - if len(fields) == 3 and fields[1] == str(manager) and 'codex-worker save' in fields[2]: - return int(fields[0]) - writer = wait_for(writer_pid) - os.kill(writer, signal.SIGKILL) - self.assertTrue(list((self.data / 'sessions/chat/checkpoints').glob('*.json'))) - wait_for(lambda: b'survives_writer_crash' in self.egg()) - wait_for(lambda: not list((self.data / 'sessions/chat/checkpoints').glob('*.json'))) - self.assertIsNone(json.loads((self.data / 'sessions/chat/pending.json').read_text())['finalMessage']) - - def test_manager_restart_preserves_uncommitted_partial_work(self): - self.start() - with open(self.root / 'work.egg.guard', 'a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - self.post(marker='survives_manager_crash') - before = self.endpoint() - os.kill(before['pid'], signal.SIGKILL) - self.hook('SessionStart') - self.assertNotEqual(self.endpoint()['secret'], before['secret']) - wait_for(lambda: b'survives_manager_crash' in self.egg()) - - def test_abandoned_partial_write_does_not_block_later_commits(self): - self.start() - self.post(marker='committed_before_crash') - wait_for(lambda: b'committed_before_crash' in self.egg()) - # Reproduce the on-disk boundary of SIGKILL before atomic rename. - # Cover both the former fixed filename and an abandoned unique file. - abandoned = [self.root / 'work.egg.tmp', self.root / 'work.egg.tmp-999999-0'] - for path in abandoned: - path.write_bytes(b'{"incomplete":') - self.post(use='after-crash', marker='committed_after_crash') - wait_for(lambda: b'committed_after_crash' in self.egg()) - self.assertIn(b'committed_before_crash', self.egg()) - json.loads(self.egg()) - wait_for(lambda: not list((self.data / 'sessions/chat/checkpoints').glob('*.json'))) - for path in abandoned: - self.assertEqual(path.read_bytes(), b'{"incomplete":') - - def test_journal_recovers_a_missing_checkpoint_without_stop_or_another_hook(self): - self.start() - # Stop the background consumer while reproducing a hook exit between - # its two atomic writes: the receipt exists but its checkpoint does not. - files = self.data / 'sessions/chat' - with open(files / 'save.guard', 'a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - self.post(marker='recovered_from_native_journal') - receipts = list((files / 'tools').glob('*/*.json')) - self.assertTrue(receipts) - unreadable = receipts[0].parent / 'unreadable.json' - unreadable.write_text('tr') - for path in (files / 'checkpoints').glob('*.json'): - path.unlink() - self.assertNotIn(b'recovered_from_native_journal', self.egg()) - wait_for(lambda: b'recovered_from_native_journal' in self.egg()) - self.assertIsNone(json.loads((files / 'pending.json').read_text())['finalMessage']) - self.assertNotIn(b'interrupted before final response', self.egg()) - wait_for(lambda: not list((files / 'checkpoints').glob('*.json'))) - self.assertEqual(unreadable.read_text(), 'tr') - - def test_manager_ownership_and_cross_chat_rejection(self): - with concurrent.futures.ThreadPoolExecutor(4) as pool: - list(pool.map(lambda _: self.hook('SessionStart'), range(4))) - self.start('second', 'second-turn') - first, second = self.endpoint(), self.endpoint('second') - self.assertNotEqual(first['port'], second['port']) - self.assertNotEqual(first['secret'], second['secret']) - self.assertFalse(rpc(first, 'hook', self.input('PostCompact', 'second'))['ok']) - # The kernel lease also rejects a duplicate manager started directly. - duplicate = subprocess.run([BIN, 'codex-daemon', 'chat'], env=self.env, - capture_output=True, timeout=3) - self.assertNotEqual(duplicate.returncode, 0) - self.assertEqual(self.endpoint()['secret'], first['secret']) - - def test_corrupt_state_can_be_disabled_without_parsing_pending_or_config(self): - self.start() - files = self.data / 'sessions/chat' - (files / 'state.json').write_text('tr') - (files / 'pending.json').write_text('tr') - working_config = self.config.read_text() - self.config.write_text('this is malformed') - result = subprocess.run([BIN, 'egg', 'off'], env=dict(self.env, CODEX_THREAD_ID='chat'), - cwd=self.root, text=True, capture_output=True, timeout=3) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(self.state()['enabled']) - self.assertTrue(list(files.glob('state.json.corrupt-*'))) - self.assertTrue(list(files.glob('pending.json.corrupt-*'))) - output, elapsed = self.hook('PostCompact') - self.assertEqual(output, {}) - self.assertLess(elapsed, 2.5) - self.config.write_text(working_config.replace('work\"', 'research\"').replace('profiles.work', 'profiles.research')) - enabled = subprocess.run([BIN, 'egg', 'on'], env=dict(self.env, CODEX_THREAD_ID='chat'), - cwd=self.root, text=True, capture_output=True, timeout=3) - self.assertEqual(enabled.returncode, 0, enabled.stderr) - self.assertTrue(self.state()['enabled']) - self.assertEqual(self.state()['profile'], 'research') - - def test_same_operation_can_reuse_new_evidence_after_an_earlier_denial(self): - self.start('seed') - self.post(session='seed', marker='first_clock_observation') - wait_for(lambda: b'first_clock_observation' in self.egg()) - self.start('reader') - first, _ = self.hook('PreToolUse', 'reader', tool_name='shell', - tool_use_id='first-read', tool_input={'command': 'cat clock.c'}) - self.assertEqual(first['hookSpecificOutput']['permissionDecision'], 'deny') - - # Another chat records a new observation of the same native Work. - # The earlier denial must not exempt this operation from evidence reuse. - self.start('second-seed') - self.post(session='second-seed', marker='second_clock_observation') - wait_for(lambda: b'second_clock_observation' in self.egg()) - second, _ = self.hook('PreToolUse', 'reader', tool_name='shell', - tool_use_id='second-read', tool_input={'command': 'cat clock.c'}) - self.assertIn('second_clock_observation', json.dumps(second)) - self.assertEqual(second['hookSpecificOutput']['permissionDecision'], 'deny') - - # Evidence-specific deduplication still prevents an unchanged receipt - # from being presented as a new reason to replan the same work. - unchanged, _ = self.hook('PreToolUse', 'reader', tool_name='shell', - tool_use_id='unchanged-read', tool_input={'command': 'cat clock.c'}) - self.assertNotIn('permissionDecision', json.dumps(unchanged)) - - def test_lost_delivery_receipt_never_marks_context_delivered(self): - self.start() - self.post() - self.hook('Stop', last_assistant_message='The clock investigation is complete') - wait_for(lambda: b'The clock investigation is complete' in self.egg()) - self.start('reader') - self.hook('PostCompact', 'reader') - endpoint = self.endpoint('reader') - response = rpc(endpoint, 'hook', self.input('PreToolUse', 'reader', - tool_name='shell', tool_use_id='read-again', tool_input={'command': 'cat clock.c'}, - _eggshell_receipt='lost-receipt')) - self.assertTrue(response['ok']) - self.assertIn('observed_clock_fact', response['output']) - self.assertFalse(any(key.startswith('g:') for key in self.state('reader')['deliveredGraphs'])) - self.hook('PostCompact', 'reader') - rpc(endpoint, 'ack', {'receipt': 'lost-receipt'}) - self.assertFalse(any(key.startswith('g:') for key in self.state('reader')['deliveredGraphs'])) - again, _ = self.hook('PreToolUse', 'reader', tool_name='shell', - tool_use_id='read-again-2', tool_input={'command': 'cat clock.c'}) - self.assertNotIn('permissionDecision', json.dumps(again)) - self.assertIn('observed_clock_fact', json.dumps(again)) - - def test_expired_search_is_reaped_and_the_next_hook_can_save(self): - self.start('seed') - self.post(session='seed') - self.hook('Stop', 'seed', last_assistant_message='clock result for retrieval') - wait_for(lambda: b'clock result for retrieval' in self.egg()) - self.hook('SessionStart', 'deadline') - provider = self.root / 'timeout-provider.py' - marker = self.root / 'timeout-provider-pid' - provider.write_text('import os,pathlib,sys,time\n' - 'sys.stdin.readline()\npathlib.Path(sys.argv[1]).write_text(str(os.getpid()))\n' - 'time.sleep(60)\n') - config = self.root / 'timeout.toml' - config.write_text(self.config.read_text().replace('semantic_matcher = false', - 'semantic_matcher = ' + json.dumps([sys.executable, str(provider), str(marker)]))) - peer_clock = int(rpc(self.endpoint('deadline'), 'ping')['output']) - start = time.monotonic() - response = rpc(self.endpoint('deadline'), 'hook', self.input('UserPromptSubmit', 'deadline', - prompt='Recall the clock result', _eggshell_config=str(config), - _eggshell_deadline=peer_clock + 800, _eggshell_receipt='expired')) - self.assertLess(time.monotonic() - start, 2) - self.assertTrue(marker.exists(), 'the hang fixture did not actually start') - self.assertEqual(json.loads(response['output']), {}) - self.assertFalse(self.state('deadline')['offers']) - self.post(session='deadline', marker='saved_after_search_deadline') - wait_for(lambda: b'saved_after_search_deadline' in self.egg()) - - def test_hung_search_does_not_block_partial_save_stop_or_another_chat(self): - self.start('seed') - self.post(session='seed') - self.hook('Stop', 'seed', last_assistant_message='clock investigation completed') - wait_for(lambda: b'clock investigation completed' in self.egg()) - provider = self.root / 'hung.py' - marker = self.root / 'provider-pids' - provider.write_text('import os,sys,time,subprocess,json,pathlib\n' - 'for line in sys.stdin:\n' - ' child=subprocess.Popen([sys.executable,"-c","import time;time.sleep(60)"])\n' - ' pathlib.Path(sys.argv[1]).write_text(json.dumps([os.getpid(),child.pid]))\n' - ' time.sleep(60)\n') - slow_config = self.root / 'slow.toml' - slow_config.write_text(self.config.read_text().replace('semantic_matcher = false', - 'semantic_matcher = ' + json.dumps([sys.executable, str(provider), str(marker)]))) - slow = subprocess.Popen([BIN, 'codex-hook'], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=self.root, - env=dict(self.env, EGGSHELL_CONFIG=str(slow_config)), start_new_session=True) - self.children.append(slow) - slow.stdin.write(json.dumps(self.input('UserPromptSubmit', 'slow', - prompt='What did we find about the clock?'))) - slow.stdin.close() - wait_for(marker.exists) - pids = json.loads(marker.read_text()) - self.post(session='slow', marker='saved_while_search_hung') - wait_for(lambda: b'saved_while_search_hung' in self.egg()) - start = time.monotonic() - self.start('independent') - self.assertLess(time.monotonic() - start, 3) - _, elapsed = self.hook('Stop', 'slow', last_assistant_message=None) - self.assertLess(elapsed, 2.5) - slow.wait(timeout=4) - self.assertEqual(slow.returncode, 0) - self.assertEqual(json.loads(slow.stdout.read()), {}) - def dead(pid): - try: - os.kill(pid, 0) - # A zombie is already terminated; its init-owned reaping is OS work. - status = subprocess.run(['ps', '-o', 'stat=', '-p', str(pid)], - capture_output=True, text=True).stdout.strip() - return status.startswith('Z') or not status - except ProcessLookupError: - return True - wait_for(lambda: all(dead(pid) for pid in pids)) - self.assertFalse(self.state('slow')['offers']) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py deleted file mode 100644 index 95ba788..0000000 --- a/tests/test_plugin_package.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Check the installation boundary and the downloadable runtime contract.""" -import hashlib -import importlib.util -import io -import json -import os -from pathlib import Path -import re -import subprocess -import tarfile -import tempfile -import unittest -import zipfile - -ROOT = Path(__file__).resolve().parent.parent - - -def module(name, file): - spec = importlib.util.spec_from_file_location(name, file) - result = importlib.util.module_from_spec(spec) - spec.loader.exec_module(result) - return result - - -setup = module('eggshell_setup', ROOT / 'plugins/eggshell/scripts/setup.py') -packager = module('eggshell_packager', ROOT / 'scripts/package_plugin.py') - - -def archive_at(file, payload=b'#!/bin/sh\nexit 0\n', symlink=False): - with tarfile.open(file, 'w:gz') as archive: - info = tarfile.TarInfo('eggshell') - if symlink: - info.type = tarfile.SYMTYPE - info.linkname = '/tmp/unrelated' - archive.addfile(info) - else: - info.size = len(payload) - archive.addfile(info, io.BytesIO(payload)) - return hashlib.sha256(file.read_bytes()).hexdigest() - - -class PackageTests(unittest.TestCase): - def test_checksum_and_archive_type(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - archive = root / 'runtime.tar.gz' - executable = root / 'eggshell' - digest = archive_at(archive) - with self.assertRaisesRegex(ValueError, 'checksum'): - setup.extract_runtime(archive, executable, '0' * 64) - self.assertFalse(executable.exists()) - setup.extract_runtime(archive, executable, digest) - self.assertEqual(subprocess.run([executable]).returncode, 0) - executable.unlink() - digest = archive_at(archive, symlink=True) - with self.assertRaisesRegex(ValueError, 'regular'): - setup.extract_runtime(archive, executable, digest) - self.assertFalse(executable.exists()) - - def test_missing_runtime_does_not_download_or_block_hooks(self): - with tempfile.TemporaryDirectory() as directory: - env = dict(os.environ, EGGSHELL_PREFIX=directory) - launcher = ROOT / 'plugins/eggshell/bin/egg' - result = subprocess.run([launcher, 'codex-hook'], env=env, capture_output=True, text=True) - self.assertEqual(result.returncode, 0) - self.assertEqual(json.loads(result.stdout), {}) - self.assertEqual(list(Path(directory).iterdir()), []) - result = subprocess.run([launcher, 'inspect'], env=env, capture_output=True, text=True) - self.assertNotEqual(result.returncode, 0) - self.assertIn('not installed', result.stderr) - - def test_missing_runtime_notice_is_startup_only_and_never_blocks(self): - with tempfile.TemporaryDirectory() as directory: - env = dict(os.environ, EGGSHELL_PREFIX=directory) - launcher = ROOT / 'plugins/eggshell/bin/egg' - hooks = json.loads((ROOT / 'plugins/eggshell/hooks/hooks.json').read_text()) - for source in ['startup', 'resume', 'clear', 'compact']: - matched = [g for g in hooks['hooks']['SessionStart'] - if re.search(g.get('matcher', ''), source)] - self.assertEqual(len(matched), 1) - entry = matched[0]['hooks'][0]['command'].split()[-1] - result = subprocess.run([launcher, entry], env=env, input=json.dumps({ - 'hook_event_name': 'SessionStart', 'source': source}), - capture_output=True, text=True, timeout=2) - self.assertEqual(result.returncode, 0, result.stderr) - reply = json.loads(result.stdout) - if source == 'compact': - self.assertEqual(reply, {}) - else: - self.assertIn('memory is not active', reply['systemMessage']) - self.assertIn('Set up Eggshell', reply['systemMessage']) - self.assertNotIn('continue', reply) - self.assertNotIn('hookSpecificOutput', reply) - self.assertEqual(list(Path(directory).iterdir()), []) - - def test_startup_launcher_forwards_the_original_hook_input(self): - with tempfile.TemporaryDirectory(prefix="egg prefix's ") as directory: - root = Path(directory) - runtime = root / 'libexec/eggshell' - runtime.parent.mkdir() - runtime.write_text('#!/bin/sh\n[ "$1" = codex-hook ] || exit 9\ncat\n') - runtime.chmod(0o755) - (root / 'libexec/eggshell.owner').write_text('o8vm/eggshell\n') - payload = json.dumps({'hook_event_name': 'SessionStart', 'cwd': "a b'c", - 'session_id': 'a-chat'}) - result = subprocess.run([ROOT / 'plugins/eggshell/bin/egg', 'codex-start'], - input=payload, text=True, capture_output=True, - env=dict(os.environ, EGGSHELL_PREFIX=directory)) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, payload) - - def test_project_setup_preserves_parent_and_global_configuration(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - project = root / 'project' - project.mkdir() - env = dict(os.environ, EGGSHELL_PREFIX=str(root / 'prefix'), - EGGSHELL_DATA_ROOT=str(root / 'data')) - env.pop('EGGSHELL_CONFIG', None) - env.pop('CODEX_THREAD_ID', None) - runtime = ROOT / '.lake/build/bin/eggshell' - report = setup.initialize_project(runtime, project, env) - self.assertEqual(report['configuration'], 'ready') - self.assertFalse(report['session_state_present']) - self.assertFalse(report['handoff_observed']) - config = project / '.eggshell.toml' - custom = config.read_text().replace('default = "work"', 'default = "private"') - config.write_text(custom) - nested = project / 'nested' - nested.mkdir() - report = setup.initialize_project(runtime, nested, env) - self.assertEqual(report['memory'], 'read-only') - self.assertEqual(config.read_text(), custom) - self.assertFalse((nested / '.eggshell.toml').exists()) - self.assertFalse((project / '.eggs/work.egg').exists()) - other = root / 'other' - other.mkdir() - global_config = root / 'prefix/config/eggshell/config.toml' - global_config.parent.mkdir(parents=True) - global_config.write_text(custom) - report = setup.initialize_project(runtime, other, env) - self.assertEqual(report['memory'], 'read-only') - self.assertFalse((other / '.eggshell.toml').exists()) - self.assertEqual(global_config.read_text(), custom) - self.assertFalse((root / 'data').exists()) - - def test_setup_check_does_not_install_or_initialize(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - result = subprocess.run(['python3', ROOT / 'plugins/eggshell/scripts/setup.py', - '--check', '--prefix', str(root / 'missing'), '--project', str(root)], - text=True, capture_output=True) - self.assertEqual(result.returncode, 1) - self.assertEqual(json.loads(result.stdout)['runtime'], 'missing') - self.assertEqual(list(root.iterdir()), []) - - def test_invalid_existing_configuration_is_reported_and_retained(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - config = root / '.eggshell.toml' - config.write_text('default = true\n') - env = dict(os.environ, EGGSHELL_PREFIX=str(root / 'prefix'), - EGGSHELL_DATA_ROOT=str(root / 'data')) - env.pop('EGGSHELL_CONFIG', None) - env.pop('CODEX_THREAD_ID', None) - with self.assertRaisesRegex(ValueError, 'expected quoted TOML string'): - setup.initialize_project(ROOT / '.lake/build/bin/eggshell', root, env) - self.assertEqual(config.read_text(), 'default = true\n') - self.assertFalse((root / '.eggs').exists()) - self.assertFalse((root / 'data').exists()) - - def test_setup_check_identifies_an_older_runtime_without_modifying_it(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - runtime = root / 'libexec/eggshell' - runtime.parent.mkdir() - old = '#!/bin/sh\n[ "$2" = --help ] || exit 99\nprintf "usage: egg [init|inspect]\\n"\n' - runtime.write_text(old) - runtime.chmod(0o755) - (runtime.parent / 'eggshell.owner').write_text('o8vm/eggshell\n') - result = subprocess.run(['python3', ROOT / 'plugins/eggshell/scripts/setup.py', - '--check', '--prefix', str(root), '--project', str(root)], - text=True, capture_output=True) - self.assertEqual(result.returncode, 1, result.stderr) - self.assertEqual(json.loads(result.stdout)['runtime'], 'update_required') - self.assertEqual(runtime.read_text(), old) - self.assertFalse((root / '.eggshell.toml').exists()) - - def test_runtime_install_preserves_plugin_and_memory(self): - with tempfile.TemporaryDirectory(prefix="egg package's ") as directory: - root = Path(directory) - prefix = root / 'prefix' - data = root / 'data' - # Exercise runtime installation without a network/model download. - # The existing MiniLM tests cover dependency setup separately. - support = prefix / 'share/eggshell/minilm' - python = support / 'fastembed-0.8.0/bin/python' - python.parent.mkdir(parents=True) - python.write_text('#!/bin/sh\nexit 97\n') - python.chmod(0o755) - (support / 'fastembed-0.8.0.model-ready').write_text('ready') - plugin = prefix / 'plugins/eggshell' - plugin.mkdir(parents=True) - (plugin / '.eggshell-owner').write_text('o8vm/eggshell\n') - (plugin / 'sentinel').write_text('existing plugin') - memory = prefix / 'work.egg' - memory.write_bytes(b'user-owned work') - marketplace = prefix / '.agents/plugins/marketplace.json' - marketplace.parent.mkdir(parents=True) - marketplace.write_text('{"keep": "unchanged"}\n') - fakebin = root / 'bin' - fakebin.mkdir() - codex = fakebin / 'codex' - codex.write_text('#!/bin/sh\nprintf called > "$EGGSHELL_PREFIX/codex-called"\nexit 91\n') - codex.chmod(0o755) - env = dict(os.environ, EGGSHELL_PREFIX=str(prefix), EGGSHELL_DATA_ROOT=str(data), - PATH=str(fakebin) + os.pathsep + os.environ['PATH']) - result = subprocess.run([ROOT / '.lake/build/bin/eggshell', 'install', 'runtime'], - env=env, capture_output=True, text=True) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse((prefix / 'codex-called').exists()) - self.assertEqual((plugin / 'sentinel').read_text(), 'existing plugin') - self.assertEqual(memory.read_bytes(), b'user-owned work') - self.assertEqual(marketplace.read_text(), '{"keep": "unchanged"}\n') - project = root / 'project' - project.mkdir() - result = subprocess.run([prefix / 'bin/egg', 'init'], cwd=project, env=env, - capture_output=True, text=True) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue((project / '.eggshell.toml').exists()) - - def test_control_uninstall_checks_ownership(self): - with tempfile.TemporaryDirectory() as directory: - prefix = Path(directory) - plugin = prefix / 'plugins/eggshell' - plugin.mkdir(parents=True) - sentinel = plugin / 'unrelated-file' - sentinel.write_text('keep') - env = dict(os.environ, EGGSHELL_PREFIX=str(prefix), - EGGSHELL_DATA_ROOT=str(prefix / 'state')) - result = subprocess.run([ROOT / '.lake/build/bin/eggshell', 'egg', 'uninstall', 'codex'], - env=env, capture_output=True, text=True) - self.assertNotEqual(result.returncode, 0) - self.assertIn('unowned Plugin directory', result.stderr) - self.assertEqual(sentinel.read_text(), 'keep') - - def test_zip_contains_portable_hooks_and_pinned_targets(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for target in packager.TARGETS: - archive_at(root / f'eggshell-{target}.tar.gz') - result = packager.package(root, root / 'output', 'v0.1.0') - with zipfile.ZipFile(result) as package: - self.assertIsNone(package.testzip()) - names = package.namelist() - self.assertIn('.codex-plugin/plugin.json', names) - self.assertIn('skills/eggshell/SKILL.md', names) - self.assertIn('bin/egg', names) - self.assertIn('hooks/hooks.json', names) - self.assertNotIn('.mcp.json', names) - self.assertFalse(any('plan' in n.lower() or '.egg' == Path(n).suffix for n in names)) - runtime = json.loads(package.read('runtime.json')) - self.assertEqual(set(runtime['targets']), set(packager.TARGETS)) - for item in runtime['targets'].values(): - self.assertEqual(hashlib.sha256((root/'output'/item['file']).read_bytes()).hexdigest(), item['sha256']) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_search_provider.py b/tests/test_search_provider.py deleted file mode 100644 index 4bd6f44..0000000 --- a/tests/test_search_provider.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Run the shipped provider, including real local MiniLM (no generative calls). - -Use the installed MiniLM Python environment to run this file. -""" -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest - - -class SearchProviderTest(unittest.TestCase): - def test_exact_symbols_and_long_outcomes_survive_indexing(self): - source = (Path(__file__).resolve().parents[1] / "Eggshell/MiniLM.lean").read_text() - code = source.split('def providerSource : String := r#"', 1)[1].split('"#', 1)[0] - models = os.environ.get("EGGSHELL_TEST_MODELS", str( - Path.home() / ".local/share/eggshell/minilm/models")) - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - provider = root / "provider.py" - provider.write_text(code) - candidates = [ - {"id": "first", "text": "Network driver documentation and packet routing."}, - {"id": "second", "text": "Historical notes. " * 100 + - "\nCONFIG_ARCHIVE_SENTINEL_7E29 requires CONFIG_STORAGE_BRIDGE."}, - ] - query = {"id": "query", "text": "CONFIG_ARCHIVE_SENTINEL_7E29"} - request = {"query": query, "candidates": candidates} - modes = ["lexical", "semantic", "hybrid"] - for mode in modes: - trace = root / (mode + ".jsonl") - command = [ - sys.executable, str(provider), "--cache", str(root / "cache"), - "--model-cache", models, "--mode", mode, "--top-k", "1", - "--threshold", "0", "--trace", str(trace), "--anchor-k", "1", - ] - result = subprocess.run(command, input=json.dumps(request) + "\n", text=True, capture_output=True, - check=True, timeout=60, env={**os.environ, "HF_HUB_OFFLINE": "1"}) - self.assertEqual(json.loads(result.stdout)["related"], [1], result.stderr) - records = [json.loads(line) for line in trace.read_text().splitlines()] - self.assertEqual(len(records), 1) - self.assertEqual(records[0]["candidate_count"], 2) - self.assertEqual(records[0]["selected"], [1]) - self.assertEqual(records[0]["anchor_rank"], [1]) - self.assertEqual(records[0]["mode"], mode) - - # Caller IDs are not cache authority: changed bytes must be reindexed. - changed = {"query": query, "candidates": [ - {"id": "first", "text": candidates[1]["text"]}, - {"id": "second", "text": candidates[0]["text"]}, - ]} - result = subprocess.run([ - sys.executable, str(provider), "--cache", str(root / "cache"), - "--model-cache", models, "--mode", "semantic", "--top-k", "1", - "--threshold", "0", - ], input=json.dumps(changed) + "\n", text=True, capture_output=True, - check=True, timeout=60, env={**os.environ, "HF_HUB_OFFLINE": "1"}) - self.assertEqual(json.loads(result.stdout)["related"], [0], result.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/Package.lean b/tools/Package.lean new file mode 100644 index 0000000..34f0ad0 --- /dev/null +++ b/tools/Package.lean @@ -0,0 +1,113 @@ +module + +public import Eggshell.Sha256 +public import Eggshell.Persistence +public import Lean + +@[expose] public section + +namespace Eggshell.Package +open Lean + +def targets : List String := ["linux-aarch64", "linux-x86_64", "macos-aarch64", "macos-x86_64"] + +def little (n bytes : Nat) : ByteArray := ByteArray.mk + ((List.range bytes).map (fun i => ((n >>> (8*i)) % 256).toUInt8)).toArray + +def crc32 (bytes : ByteArray) : UInt32 := Id.run do + let mut crc : UInt32 := 0xffffffff + for byte in bytes do + crc := crc ^^^ byte.toUInt32 + for _ in [0:8] do crc := if crc &&& 1 == 1 then (crc >>> 1) ^^^ 0xedb88320 else crc >>> 1 + return crc ^^^ 0xffffffff + +structure Entry where + name : String + bytes : ByteArray + executable : Bool := false + +/-- Stored ZIP entries avoid a second compression runtime. Archives use stable + order, timestamps and Unix permissions; TAR runtime assets stay compressed. -/ +def zip (entries : List Entry) : ByteArray := Id.run do + let mut output := ByteArray.empty + let mut directory := ByteArray.empty + for entry in entries do + let name := entry.name.toUTF8 + let size := entry.bytes.size + let checksum := (crc32 entry.bytes).toNat + let offset := output.size + output := output ++ little 0x04034b50 4 ++ little 20 2 ++ little 0x800 2 ++ + little 0 2 ++ little 0 2 ++ little 23585 2 ++ little checksum 4 ++ + little size 4 ++ little size 4 ++ little name.size 2 ++ little 0 2 ++ name ++ entry.bytes + directory := directory ++ little 0x02014b50 4 ++ little 0x314 2 ++ little 20 2 ++ little 0x800 2 ++ + little 0 2 ++ little 0 2 ++ little 23585 2 ++ little checksum 4 ++ little size 4 ++ little size 4 ++ + little name.size 2 ++ little 0 2 ++ little 0 2 ++ little 0 2 ++ little 0 2 ++ + little ((if entry.executable then 0o100755 else 0o100644) * 65536) 4 ++ little offset 4 ++ name + let tail := little 0x06054b50 4 ++ little 0 2 ++ little 0 2 ++ little entries.length 2 ++ + little entries.length 2 ++ little directory.size 4 ++ little output.size 4 ++ little 0 2 + return output ++ directory ++ tail + +partial def collect (root : System.FilePath) (relative := "") : IO (List Entry) := do + let mut entries := [] + for entry in (← (root / relative).readDir).toList.mergeSort (fun a b => a.fileName ≤ b.fileName) do + if entry.fileName == "__pycache__" || entry.fileName.endsWith ".pyc" then continue + let path := if relative.isEmpty then entry.fileName else relative ++ "/" ++ entry.fileName + let metadata ← entry.path.symlinkMetadata + match metadata.type with + | .dir => entries := entries ++ (← collect root path) + | .file => entries := entries ++ [⟨path, ← IO.FS.readBinFile entry.path, relative == "bin"⟩] + | _ => throw (IO.userError s!"non-regular package entry: {entry.path}") + return entries + +def safeName (name : String) : Bool := !name.isEmpty && name.toList.all fun c => + c.isAlphanum || "-_.".contains c + +def build (root runtimeDir output : System.FilePath) (release : String) : IO Unit := do + if !safeName release then throw (IO.userError "invalid release name") + let manifest ← IO.ofExcept (Json.parse (← IO.FS.readFile (root / "plugins/eggshell/.codex-plugin/plugin.json"))) + let version ← IO.ofExcept (manifest.getObjValAs? String "version") + if release != "v" ++ version then throw (IO.userError "release must match plugin version") + IO.FS.createDirAll output + let source := (← IO.Process.run { cmd := "git", args := #["rev-parse", "HEAD"], cwd := some root }).trimAscii.toString + let mut runtimeTargets := [] + let mut pins : List Entry := [] + for target in targets do + let archive := runtimeDir / ("eggshell-" ++ target ++ ".tar.gz") + let bytes ← IO.FS.readBinFile archive + let checksum := Sha256.hex bytes + let file := "eggshell-runtime-" ++ target ++ "-" ++ String.ofList (checksum.toList.take 16) ++ ".tar.gz" + IO.FS.writeBinFile (output / file) bytes + runtimeTargets := runtimeTargets ++ [(target, Json.mkObj [("file", .str file), ("sha256", .str checksum)])] + pins := pins ++ [⟨"runtime-pins/" ++ target, (release ++ "\n" ++ file ++ "\n" ++ checksum ++ "\n").toUTF8, false⟩] + let runtime := Json.mkObj [("release", .str release), ("source_commit", .str source), + ("targets", Json.mkObj runtimeTargets)] + let mut interface ← IO.ofExcept (manifest.getObjVal? "interface") + for (key, value) in [("logo", .str "./assets/icon.png"), ("composerIcon", .str "./assets/icon.png"), + ("privacyPolicyURL", .str "https://github.com/momonpya/eggshell/blob/main/PRIVACY.md")] do + interface := interface.setObjVal! key value + let manifest := manifest.setObjVal! "skills" (.str "./skills") |>.setObjVal! "interface" interface + let original ← collect (root / "plugins/eggshell") + let entries := (original.filter fun e => e.name != ".codex-plugin/plugin.json" && e.name != "runtime.json" && + !(e.name.startsWith "runtime-pins/")) ++ pins ++ [ + ⟨".codex-plugin/plugin.json", (manifest.pretty ++ "\n").toUTF8, false⟩, + ⟨"runtime.json", (runtime.pretty ++ "\n").toUTF8, false⟩, + ⟨"assets/icon.png", ← IO.FS.readBinFile (root / "docs/assets/brand/eggshell-app-icon-dark-1024.png"), false⟩, + ⟨"LICENSE", ← IO.FS.readBinFile (root / "LICENSE"), false⟩] + let archive := zip (entries.mergeSort (fun a b => a.name ≤ b.name)) + if archive.size > 100000000 then throw (IO.userError "plugin ZIP exceeds 100 MB") + IO.FS.writeBinFile (output / "eggshell-codex-plugin.zip") archive + IO.FS.writeFile (output / "runtime.json") (runtime.pretty ++ "\n") + IO.println s!"Packaged {entries.length} entries, {archive.size} bytes, four pinned runtime assets" + +end Eggshell.Package + +def main (args : List String) : IO UInt32 := do + match args with + | ["--runtime-dir", runtimes, "--output", output, "--release", release] => + Eggshell.Package.build (← IO.currentDir) (.mk runtimes) (.mk output) release + pure 0 + | ["--version"] => + let json ← IO.ofExcept (Lean.Json.parse (← IO.FS.readFile "plugins/eggshell/.codex-plugin/plugin.json")) + IO.println (← IO.ofExcept (json.getObjValAs? String "version")) + pure 0 + | _ => throw (IO.userError "package --runtime-dir PATH --output PATH --release vVERSION") diff --git a/tools/Render.lean b/tools/Render.lean new file mode 100644 index 0000000..967d050 --- /dev/null +++ b/tools/Render.lean @@ -0,0 +1,47 @@ +module + +public import Eggshell.Sha256 + +@[expose] public section + +open Eggshell + +def run (cmd : String) (args : Array String) : IO Unit := do + let _ ← IO.Process.run { cmd, args } + +/-- Version-controlled SVGs are the editable artwork. Rasterization and video + encoding are delegated to their native rendering tools, never to Python. -/ +def social : IO Unit := do + let root : System.FilePath := "docs/assets/brand" + for theme in ["light", "dark"] do + let name := "github-social-preview-" ++ theme ++ "-1280x640" + run "rsvg-convert" #[(root / (name ++ ".svg")).toString, "-o", (root / (name ++ ".png")).toString] + +def demo : IO Unit := do + let record ← IO.FS.readBinFile "docs/benchmarks/llvm-follow-up.json" + if Sha256.hex record != "a1dd0a7d1abffbbe7322279769fb20328ef0e42009d558e7d472a26b26c3d498" then + throw (IO.userError "Measurement record changed; review the SVG figures and update their evidence fingerprint before rendering") + let root ← IO.FS.realPath "docs/assets/demo" + let temporary ← IO.FS.createTempDir + try + let names := ["01-investigate", "02-reuse", "03-continue", "04-results"] + let mut images := #[] + for name in names do + let image := temporary / (name ++ ".png") + run "rsvg-convert" #[(root / (name ++ ".svg")).toString, "-o", image.toString] + images := images.push image.toString + run "magick" (#["-delay", "750"] ++ images ++ #["-loop", "0", "-layers", "Optimize", (root / "walkthrough.gif").toString]) + let frames := String.intercalate "" (images.toList.map fun image => "file '" ++ image ++ "'\nduration 7.5\n") ++ + "file '" ++ images.back! ++ "'\n" + let concat := temporary / "frames.txt" + IO.FS.writeFile concat frames + run "ffmpeg" #["-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "0", + "-i", concat.toString, "-t", "30", "-r", "24", "-c:v", "libx264", "-crf", "20", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", (root / "walkthrough.mp4").toString] + finally IO.FS.removeDirAll temporary + +def main (args : List String) : IO UInt32 := do + match args with + | ["social"] => social *> pure 0 + | ["demo"] => demo *> pure 0 + | _ => throw (IO.userError "usage: eggshell_render social|demo")