diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml
new file mode 100644
index 0000000..70c3c8e
--- /dev/null
+++ b/.github/workflows/adapters.yml
@@ -0,0 +1,38 @@
+name: Adapter contracts
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'adapters/**'
+ - 'Eggshell/**'
+ - 'adapters/native/Tests.lean'
+ - 'tests/test_opencode_adapter.mjs'
+ - '.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 eggshell_bridge adapter_tests
+ - name: Engine integration contracts
+ 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/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/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/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/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 4dfb9d7..a0d9ea7 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 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`
+ 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..026f9d0 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.
-
-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.
-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
+[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.
+
+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.**
-For a standalone installation from a terminal:
+
+Install from a terminal instead
```sh
curl --proto '=https' --tlsv1.2 -fsSL \
@@ -85,6 +90,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 +128,14 @@ For shared work files, custom install locations, and troubleshooting, see the
## How it works
-
-
-
-
-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 +146,11 @@ 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.
+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.
@@ -159,6 +166,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 +182,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 +237,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/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/.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..169a213
--- /dev/null
+++ b/adapters/README.md
@@ -0,0 +1,181 @@
+# 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, 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:
+
+```sh
+lake build eggshell
+.lake/build/bin/eggshell install runtime
+export PATH="${EGGSHELL_PREFIX:-$HOME/.local}/bin:$PATH"
+(cd adapters/native && lake build eggshell_bridge)
+```
+
+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
+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
+`${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.
+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 |
+| --- | --- |
+| 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
+"$HOME/.local/share/eggshell-adapters/eggshell-bridge" \
+ control claude --session NATIVE_SESSION_ID doctor
+"$HOME/.local/share/eggshell-adapters/eggshell-bridge" \
+ 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
+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
+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 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.
+ 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 eggshell_bridge adapter_tests && .lake/build/bin/adapter_tests)
+node --test tests/test_opencode_adapter.mjs
+```
+
+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. 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/),
+[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/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/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
new file mode 100644
index 0000000..7e77c87
--- /dev/null
+++ b/adapters/native/Main.lean
@@ -0,0 +1,36 @@
+module
+
+public import Adapter.Runtime
+public import Adapter.Install
+public import Eggshell.SearchProvider
+
+@[expose] public section
+
+open Lean Eggshell Eggshell.Plugin Eggshell.Adapter
+
+def main (args : List String) : IO UInt32 := do
+ try
+ 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}"
+ 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/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..3cd0de7
--- /dev/null
+++ b/adapters/native/lakefile.lean
@@ -0,0 +1,16 @@
+import Lake
+open Lake DSL
+
+package eggshellAdapters where
+ leanOptions := #[⟨`warningAsError, true⟩]
+
+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/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..2c13f1a
--- /dev/null
+++ b/adapters/opencode.mjs
@@ -0,0 +1,130 @@
+import { spawn } from 'node:child_process';
+import { randomUUID } from 'node:crypto';
+import { fileURLToPath } from 'node: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(bridge, [mode, 'opencode'], {
+ 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/assets/brand/github-social-preview-1280x640.png b/docs/assets/brand/github-social-preview-1280x640.png
index c6077d8..ae30189 100644
Binary files a/docs/assets/brand/github-social-preview-1280x640.png and b/docs/assets/brand/github-social-preview-1280x640.png differ
diff --git a/docs/assets/brand/github-social-preview-1280x640.svg b/docs/assets/brand/github-social-preview-1280x640.svg
index 1c29bf0..b916369 100644
--- a/docs/assets/brand/github-social-preview-1280x640.svg
+++ b/docs/assets/brand/github-social-preview-1280x640.svg
@@ -1,39 +1,4 @@
-
+
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 0000000..386dae4
Binary files /dev/null and b/docs/assets/brand/github-social-preview-dark-1280x640.png differ
diff --git a/docs/assets/brand/github-social-preview-dark-1280x640.svg b/docs/assets/brand/github-social-preview-dark-1280x640.svg
new file mode 100644
index 0000000..53c84dc
--- /dev/null
+++ b/docs/assets/brand/github-social-preview-dark-1280x640.svg
@@ -0,0 +1,10 @@
+
+ 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 0000000..1a1a789
Binary files /dev/null and b/docs/assets/brand/github-social-preview-light-1280x640.png differ
diff --git a/docs/assets/brand/github-social-preview-light-1280x640.svg b/docs/assets/brand/github-social-preview-light-1280x640.svg
new file mode 100644
index 0000000..d099762
--- /dev/null
+++ b/docs/assets/brand/github-social-preview-light-1280x640.svg
@@ -0,0 +1,10 @@
+
+ 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..086b93c 100644
--- a/docs/brand.md
+++ b/docs/brand.md
@@ -1,5 +1,8 @@
-
+
+
+
+
# 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,20 @@ 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.
+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 dab02f0..1556c0b 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
+`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
+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/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/.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
deleted file mode 100644
index f6cb608..0000000
--- a/plugins/eggshell/scripts/setup.py
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/usr/bin/env python3
-"""Install the pinned local runtime without registering another Codex plugin."""
-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 main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--prefix', default=os.environ.get('EGGSHELL_PREFIX', str(Path.home() / '.local')))
- args = parser.parse_args()
- prefix = Path(args.prefix).expanduser()
- if not prefix.is_absolute():
- raise ValueError('--prefix must be an absolute path.')
- 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'])
- 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.')
-
-
-if __name__ == '__main__':
- try:
- 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 9fc8480..560e05f 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 `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`.
- This installs only the runtime. Do not run the standalone release installer
+ `EGGSHELL_PREFIX`. Once setup is authorized, run
+ `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.
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/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 4301b41..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 Codex project.',
- 'Show what Eggshell handed to this task and why it was selected.',
- 'Help me inspect pending Eggshell work before keeping it.']})
- 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/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_hook_lifecycle.py b/tests/test_hook_lifecycle.py
deleted file mode 100644
index 8fa83b6..0000000
--- a/tests/test_hook_lifecycle.py
+++ /dev/null
@@ -1,400 +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_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_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)));
+});
diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py
deleted file mode 100644
index 48213b6..0000000
--- a/tests/test_plugin_package.py
+++ /dev/null
@@ -1,151 +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 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_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")