Skill: jas0n1ee/gemini-smart-search (version 0.1.1)

--- name: gemini-smart-search description: Search the web using Gemini with Google Search grounding through a local script, with model routing and quota fallback across Gemini Flash-Lite / Flash variants. Use when web research should stay inside the Gemini family, when dynamic model switching is needed without restarting the OpenClaw gateway, when a separate Gemini API key/quota pool should be used, or when repeated search tasks need cheap/balanced/deep modes with structured JSON output. metadata: { "openclaw": { "requires": { "bins": ["python3"] }, "primaryEnv": "SMART_SEARCH_GEMINI_API_KEY" } } --- # Gemini Smart Search Use this skill when Gemini should be the search backend, but gateway-level `web_search` config is too static or too disruptive to change. ## Purpose This skill is a **script-backed search workflow**, not a gateway tool override. It exists to provide: - dynamic Gemini model selection - quota-aware fallback - a separate Gemini API key path if desired - structured JSON output - no gateway restart requirement for model changes ## Modes Model routing is split into two layers: - **display chain**: human-facing preferred model family labels - **candidate API ids**: the actual model ids to probe, especially for 3.x preview-era models Current display chains: - **cheap** - Prefer `gemini-2.5-flash-lite` - Then `gemini-3.1-flash-lite` - Then `gemini-2.5-flash` - **balanced** - Prefer `gemini-2.5-flash` - Then `gemini-3-flash` - Then `gemini-2.5-flash-lite` - **deep** - Prefer `gemini-3-flash` - Then `gemini-2.5-flash` - Then `gemini-3.1-flash-lite` For 3.x models, do not assume the UI label is the raw API id. Probe candidate ids such as preview-suffixed names when needed. ## Invocation Run the Python script or the shell wrapper via `exec` and request JSON output. Python is now the canonical entrypoint because it also loads repo-local `.env.local` when present. The shell wrapper remains a convenience layer. Primary example (preferred): ```bash python3 skills/gemini-smart-search/scripts/gemini_smart_search.py \ --query "BoundaryML context engineering" \ --mode cheap \ --json ``` Wrapper example (convenience only): ```bash bash skills/gemini-smart-search/scripts/gemini_smart_search.sh \ --query "BoundaryML context engineering" \ --mode cheap \ --json ``` `python -m gemini_smart_search` may work when run from the `scripts/` directory, but it is **not** a supported interface for agents right now. Do not depend on it. ## Output contract Expect JSON with at least: - `ok` - `query` - `mode` - `model_used` - `fallback_chain` - `display_chain` - `answer` - `citations` - `error` - `escalation` Notes: - `model_used` is the **actual probed API model id** (for example `gemini-3-flash-preview`), not the human-facing display label. - Citation URLs may initially be Google/Vertex grounding redirect URLs instead of canonical source URLs; treat that as a known current limitation. - With `--json`, supported runtime paths should return structured JSON on both success and error. Invalid CLI arguments now also return JSON when `--json` is present. ## API key policy The script should prefer a dedicated key path for this skill, then fall back to the standard Gemini key. Required key resolution order: 1. `SMART_SEARCH_GEMINI_API_KEY` (primary declared env) 2. `GEMINI_API_KEY` (compatibility fallback) If neither key is present, the agent must explicitly ask the human for a Gemini API key before claiming setup is complete. Do not store the key in tracked repository files. Prefer a repo-local, gitignored file such as `.env.local`. See `references/config.md`. ## When to use this skill instead of built-in `web_search` Use this skill when: - you want Gemini-only search - you want to test or isolate quota pools - you want model routing without touching gateway config - you want predictable JSON output for downstream orchestration Do **not** use this skill when: - a normal built-in `web_search` is sufficient - you need non-Gemini providers - you only need to fetch and read a known URL (`web_fetch`) - you need logged-in or JS-heavy page interaction (`browser`) ## Fallback policy Fallback only for errors like: - quota exceeded / 429 - model unavailable - transient upstream failure Do not silently fallback on obvious local/script bugs or invalid arguments. ## References - `references/config.md` — environment variables and design notes - `references/qa-test-plan.md` — focused QA scope for v1 behavior and release gates - `references/qa-results-2026-03-12.md` — CLI-oriented QA outcomes from the current release cycle - `references/agent-qa-cases.md` — adversarial agent-style misuse review - `references/model-id-recon.md` — verified callable Gemini model IDs and mapping notes - `references/escalation-design.md` — when to return a GitHub issue URL for human escalation - `references/release-checklist.md` — artifact release checklist with current completion status - `references/development-goals-v0.1.1.md` — next small version scope and artifact policy - `references/release-notes-v0.1.1.md` — release notes for the current artifact - `assets/example-output.json` — expected response shape - `scripts/smoke_test.sh` — non-destructive local smoke checks for the scaffold - `scripts/prepare_artifact.sh` — deterministic clean artifact export helper ## Status Python implementation is now wired for a first real version: - direct Gemini API call path - Google Search grounding enabled - mode-based model routing - Python-side repo-local `.env.local` loading - fallback across Gemini Flash-Lite / Flash variants for retryable upstream errors - structured JSON output for orchestration This is still intentionally minimal: it does not yet expose advanced tuning flags, caching, or richer citation post-processing. Before publishing an artifact, consult `references/release-checklist.md`, review `references/development-goals-v0.1.1.md`, run `scripts/prepare_artifact.sh`, and publish the release note in `references/release-notes-v0.1.1.md` alongside the artifact.

path: references/escalation-design.md
content: # Escalation / Issue URL Design

`gemini-smart-search` is primarily for agent use, but some failures should escalate cleanly to a human instead of pretending the skill can always self-heal.

## Principle

Normal operational failures should return normal errors.

Exceptional failures that suggest an implementation gap, model-routing mismatch, contract drift, or persistent upstream incompatibility should return an `escalation` object that points a human to file a GitHub issue.

## Baseline issue URL

```text
https://github.com/jas0n1ee/gemini-smart-search/issues/new
```

Future versions may prefill title/body query parameters.

## Suggested escalation kinds

- `bug`
- `model-routing`
- `api-compat`
- `docs-mismatch`
- `qa-gap`

## When escalation SHOULD be suggested

### 1. Implementation / contract failures
- unexpected response schema shape
- grounding metadata shape drift
- internal code path not covered / impossible-state bug
- stable local reproducible failure that is not a user/config mistake

### 2. Model-routing reality mismatch
- UI model label and callable model IDs diverge in a way the skill no longer handles
- preview / non-preview naming flips and breaks routing
- all candidate IDs for a documented display model fail in a suspicious way

### 3. Skill contract drift
- canonical entrypoint no longer matches docs
- `--json` contract breaks for supported usage
- wrapper and Python entrypoints diverge again
- fallback behavior violates documented policy

### 4. Persistent upstream incompatibility
- grounding appears unavailable for a model that should support it
- citations are systematically malformed or unusable
- API behavior changes in a way that requires code or docs updates

## When escalation SHOULD NOT be suggested

Do not tell the human to open a GitHub issue for normal operational cases such as:
- missing API key
- quota exceeded on a single attempted model
- transient network errors on a single attempted model
- weird/low-quality query outcomes
- invalid user CLI arguments
- a single low-signal result set

Current v1 code caveat:
- if the entire fallback chain is exhausted, the script currently returns `error.type=all_models_failed` and marks `escalation.should_open_issue=true`, even when the underlying per-model causes were retryable upstream failures.
- Treat that as the **current shipped contract**, not an idealized policy. Human triage should still inspect `error.attempts` before deciding whether this is a real repo issue versus temporary quota/upstream exhaustion.

## Output shape

Always include `escalation` in the JSON response.

### Non-escalated example

```json
{
  "escalation": {
    "should_open_issue": false,
    "issue_url": null,
    "reason": null,
    "kind": null,
    "recommended_details": []
  }
}
```

### Escalated example

```json
{
  "escalation": {
    "should_open_issue": true,
    "issue_url": "https://github.com/jas0n1ee/gemini-smart-search/issues/new",
    "reason": "Unexpected grounding schema mismatch",
    "kind": "api-compat",
    "recommended_details": [
      "query",
      "mode",
      "display_chain",
      "fallback_chain",
      "model_used",
      "usage.attempted_models",
      "error.type",
      "error.message"
    ]
  }
}
```

## First-version recommendation

V1 should keep the logic simple:
- always include an `escalation` field
- default it to the non-escalated shape
- set it only for clearly suspicious/systemic cases
- avoid issue spam for normal user/runtime failures


path: references/agent-qa-cases.md
content: # Agent QA Cases for `gemini-smart-search`

Date: 2026-03-12
Tester stance: adversarial / messy-agent usage, not happy-path only.

## Summary

I ran 10 cases across live calls, smoke checks, and controlled fallback simulation.

Overall status: **usable but doc-polish-needed**.

What looks solid:
- JSON success/error contract is mostly stable.
- Missing-key path is graceful and structured under `--json`.
- Fallback logic works for retryable upstream/model errors and stops on non-retryable local failures.
- `.env.local` is correctly gitignored.
- Direct Python entrypoint does load repo-local `.env.local`, matching current docs.

What is confusing or worth fixing:
- `python -m gemini_smart_search` works from `scripts/`, but this entrypoint is intentionally not supported for agents and should stay documented that way.
- Grounding citations come back as Google redirect URLs (`vertexaisearch.cloud.google.com/...`), not clean source URLs; this is valid but awkward for downstream agents/users.
- Fallback-chain reporting is good, but docs should stay explicit that `model_used` is the actual API id and may differ from display labels.

---

## Cases

### Case 1 — Non-destructive smoke suite
**Command**
```bash
bash skills/gemini-smart-search/scripts/smoke_test.sh
```
**Expected**
- parse all modes
- JSON missing-key path works
- `.env.local` ignore check passes
**Observed**
- PASS for `cheap`, `balanced`, `deep`
- PASS for `.env.local` gitignore
**Result**: PASS
**Lesson**: baseline smoke coverage is decent and quota-safe.

### Case 2 — Correct usage via shell wrapper + live cheap mode
**Command**
```bash
skills/gemini-smart-search/scripts/gemini_smart_search.sh \
  --query "BoundaryML context engineering" \
  --mode cheap \
  --json
```
**Observed**
- `ok: true`
- `model_used: gemini-2.5-flash-lite`
- citations populated
**Result**: PASS
**Lesson**: documented wrapper path works as advertised.

### Case 3 — Correct usage via direct Python + live balanced mode
**Command**
```bash
python3 skills/gemini-smart-search/scripts/gemini_smart_search.py \
  --query "BoundaryML context engineering" \
  --mode balanced \
  --json
```
**Observed**
- `ok: true`
- `model_used: gemini-2.5-flash`
**Result**: PASS
**Lesson**: Python really is a valid first-class entrypoint, not just an implementation detail.

### Case 4 — Weird realistic query + deep mode
**Command**
```bash
python3 skills/gemini-smart-search/scripts/gemini_smart_search.py \
  --query 'site:arxiv.org "3D Gaussian Splatting" "anisotropic" -pdf after:2025' \
  --mode deep \
  --json
```
**Observed**
- `ok: true`
- attempted models: `gemini-3-flash-preview` → `gemini-3-flash` → `gemini-2.5-flash`
- final `model_used: gemini-2.5-flash`
**Result**: PASS
**Lesson**: this was a real proof that mode routing/fallback is not purely theoretical.

### Case 5 — Missing-key behavior
**Command**
```bash
env -i PATH="$PATH" GEMINI_SMART_SEARCH_SKIP_LOCAL_ENV=1 \
  python3 skills/gemini-smart-search/scripts/gemini_smart_search.py \
  --query "missing key check" --mode cheap --json
```
**Observed**
- exit code `1`
- structured JSON error:
  - `type: missing_api_key`
  - clear setup message
**Result**: PASS
**Lesson**: good failure mode for orchestrators.

### Case 6 — Non-JSON mode output
**Command**
```bash
python3 skills/gemini-smart-search/scripts/gemini_smart_search.py \
  --query "BoundaryML context engineering" \
  --mode cheap
```
**Observed**
- human-readable answer
- source list printed below
**Result**: PASS
**Lesson**: workable for humans, but orchestration should continue to prefer `--json`.

### Case 7 — Invalid mode with `--json`
**Command**
```bash
python3 skills/gemini-smart-search/scripts/gemini_smart_search.py \
  --query x --mode turbo --json
```
**Observed**
- exit code `2`
- structured JSON error on stderr
- `error.type: invalid_arguments`
**Result**: PASS after fix
**Lesson**: `--json` now covers CLI argument validation failures too.

### Case 8 — Wrong entrypoint assumption: try to execute `SKILL.md` as Python
**Command**
```bash
python3 skills/gemini-smart-search/SKILL.md
```
**Observed**
- syntax error, obviously
**Result**: EXPECTED FAIL
**Lesson**: sounds silly, but agents do confuse skill docs with executable entrypoints. Keep invocation guidance very explicit and near the top.

### Case 9 — Agent-style misuse: assume module entrypoint name works
**Command**
```bash
cd skills/gemini-smart-search/scripts
python3 -m gemini_smart_search --query x --mode cheap --json
```
**Observed**
- surprisingly works
- returned valid JSON success
**Result**: PASS but undocumented
**Lesson**: agents may discover and rely on `python -m gemini_smart_search`. Decide whether to officially support it or warn against it.

### Case 10 — Simulated fallback boundary checks
I imported the Python module and monkeypatched `call_gemini` to verify behavior under controlled failures.

#### 10a. Retryable upstream/model errors should fallback
**Simulation**
- `gemini-2.5-flash-lite` → retryable 429
- `gemini-3.1-flash-lite-preview` → retryable model unavailable
- `gemini-3.1-flash-lite` → success
**Observed**
- final `model_used: gemini-3.1-flash-lite`
- attempted models recorded in order
**Result**: PASS

#### 10b. Local non-retryable bug should stop, not fallback forever
**Simulation**
- every call raises `SearchError(type='local_bug', retryable=False)`
**Observed**
- immediate failure after first attempted model
- no silent fallback to the rest of the chain
**Result**: PASS
**Lesson**: fallback boundary is implemented sensibly.

---

## Additional observations

### JSON contract check
Live success payload contained the expected top-level keys:
- `ok`
- `query`
- `mode`
- `model_used`
- `fallback_chain`
- `display_chain`
- `answer`
- `citations`
- `usage`
- `error`

This is slightly richer than the minimum contract in the skill doc and example output.

### API key precedence
By direct function check:
- if only `GEMINI_API_KEY` is present, it is used
- if `SMART_SEARCH_GEMINI_API_KEY` is also present, that one wins

This matches the documented policy.

### Citation URL quality
Citations often use Gemini/Vertex grounding redirect URLs instead of clean canonical URLs. This is not wrong, but it is awkward for:
- human reading
- deduplication
- downstream fetch steps
- storing references in notes/docs

---

## Recommended fixes

1. **Move Python invocation above wrapper invocation in `SKILL.md`.** ✅
2. **Document whether `python -m gemini_smart_search` is supported.** ✅ documented as unsupported for agents
3. **Clarify JSON guarantees.** ✅ `--json` now returns structured JSON for CLI argument validation errors too
4. **Document `model_used` as an API id, not a display label.** ✅
5. **Consider normalizing citation URLs.**
   - If grounding metadata exposes canonical URLs anywhere deeper in the response, prefer those over redirect links.
   - If not possible, at least document that redirect URLs are expected. ✅ documented; canonicalization still pending
6. **Add one automated fallback unit test harness.**
   - The simulated tests above were easy and high-signal; they belong in a checked-in test script eventually.

## Bottom line

The skill is already **practically usable**. The biggest remaining issues are not core logic failures; they are **agent ergonomics** and **contract clarity**:
- what exact entrypoints are supported,
- when JSON is guaranteed,
- and what kind of model/citation identifiers downstream agents should expect.


path: references/vnext-review-2026-03-12.md
content: # VNext review — 2026-03-12

Scope: restrained product/architecture review after v1 scaffold + QA.

## Recommended next-version ideas

### 1. Canonical citation normalization
Highest leverage product improvement.

Why:
- Current citations are often Google/Vertex grounding redirect URLs, which are bad for human trust, dedupe, downstream `web_fetch`, and durable notes.
- The skill’s output is already JSON-first, so citation quality is a large fraction of perceived quality.

What to do:
- Prefer canonical publisher/source URLs if grounding metadata exposes them.
- If not directly exposed, add a small deterministic normalization layer for known redirect patterns.
- Preserve raw grounding URL separately if needed for debugging, but make the default `citations[].url` the best canonical URL available.

Why this beats other polish:
- Improves every successful query, not just failure cases.
- Makes the skill much more usable as an orchestration primitive.

### 2. Deterministic fallback test harness
Highest leverage engineering improvement.

Why:
- The core promise of this skill is model routing + fallback.
- Live/manual QA proved the concept, but the fragile part is still mostly tested by ad hoc runs.
- Without deterministic tests, future model-routing edits can silently regress stop/fallback boundaries.

What to do:
- Add a checked-in test harness that monkeypatches or injects a fake `call_gemini`.
- Cover only the decision logic, not live API behavior.
- Minimum matrix:
  - retryable 429/unavailable -> continue fallback
  - non-retryable local/config error -> stop immediately
  - all candidates fail -> `all_models_failed`
  - `usage.attempted_models` order remains stable
  - escalation object is set only for suspicious/systemic cases

Why this matters:
- Lets the repo evolve model mappings safely.
- Reduces dependence on quota and current upstream availability for confidence.

### 3. Lightweight model recon automation
Worth doing, but smaller than the two items above.

Why:
- The current repo already discovered that UI labels and callable IDs drift.
- That drift is exactly the kind of thing that quietly rots a routing skill.

What to do:
- Add a small script that lists available models and optionally probes a curated candidate set.
- Output a compact snapshot usable to refresh `references/model-id-recon.md` and spot dead candidates.
- Keep it advisory; do not auto-rewrite production mappings yet.

Why only lightweight:
- Full self-mutating routing is overkill for now.
- A cheap recon/report step gets most of the value with less risk.

## Safe to defer

- Richer escalation UX such as prefilled GitHub issue bodies/titles. Current escalation shape is already adequate for v1.
- Broader mode surface or advanced tuning flags. The skill wins by being narrow and predictable.
- Adding `*-latest` rolling aliases as primary routing targets. Nice for experimentation, but pinned IDs are the safer default.
- True "deep" tier redesign with Pro models. That is a product/billing decision, not a near-term architecture must-have.

## Bottom line

If only one vNext item gets built, make it canonical citation normalization.
If two get built, add deterministic fallback testing.
Model recon automation is good third priority, but safe to stage after the first two.


path: references/qa-test-plan.md
content: # Gemini Smart Search QA Test Plan

Focus on the first implementation only. Keep tests non-destructive and JSON-first.

## Scope

Validate these behaviors before calling the first implementation "usable":

1. repo-local env loading
2. JSON contract stability
3. fallback behavior boundaries
4. ignored secret file behavior
5. non-destructive smoke execution

## High-priority findings from scaffold review

- Python is now intended to be the canonical entrypoint.
- Repo-local `.env.local` should be loaded consistently by Python, with the shell wrapper remaining a convenience layer.
- QA should keep checking that direct Python invocation and wrapper invocation behave equivalently for env resolution.

## Test matrix

### 1) Local env loading

Goal: confirm repo-local `.env.local` is loaded only through the intended entrypoint, or move loading into Python if direct invocation must remain supported.

Checks:
- Wrapper invocation loads `.env.local` and exposes the preferred key.
- Direct Python invocation behavior is explicitly defined and tested.
- Precedence is correct:
  1. `SMART_SEARCH_GEMINI_API_KEY`
  2. `GEMINI_API_KEY`
- Missing-key path returns structured JSON, not a traceback.
- [REDACTED_SECRET] is treated as a **Python-entrypoint** test control only; wrapper parity tests must account for the wrapper sourcing `.env.local` before exec.

Suggested assertions:
- `error.api_key_present` flips predictably under controlled env conditions.
- No key value is ever echoed into stdout/stderr.

### 2) JSON contract

Goal: keep orchestrator-facing output stable.

Required top-level keys:
- `ok`
- `query`
- `mode`
- `model_used`
- `fallback_chain`
- `answer`
- `citations`
- `error`

Also verify:
- `usage.provider == "gemini"`
- `usage.grounding` is boolean
- `citations` is always a list
- `fallback_chain` is always a list
- error responses remain JSON when `--json` is used
- success responses set `error: null`

### 3) Fallback behavior

Goal: fallback only on model/provider-side issues, not on local bugs.

Must fallback on:
- 429 / quota exceeded
- model unavailable / unsupported
- transient upstream failure

Must not fallback on:
- invalid CLI arguments
- local import/config bugs
- malformed local response parsing

Suggested implementation-time tests:
- stub model call results to simulate `429`, `503`, and permanent local exceptions
- assert attempted model order matches mode chain
- assert final JSON includes the full attempted chain or enough detail to audit routing

### 4) Ignored secret file behavior

Goal: secret-bearing local config stays untracked.

Checks:
- `.env.local` is matched by `.gitignore`
- `git check-ignore .env.local` succeeds
- `git status --short --ignored` shows `.env.local` as ignored, not tracked
- no sample key is stored in tracked files

### 5) Non-destructive smoke tests

Goal: basic confidence without spending quota or mutating state.

Smoke tests should avoid real web calls by default.

Recommended minimum smoke set:
- parse CLI args for all three modes
- emit valid JSON with `--json`
- wrapper path works without traceback
- missing-key path stays graceful
- ignored-file check passes
- use [REDACTED_SECRET] so missing-key smoke checks stay quota-free even when a local `.env.local` exists

Optional live smoke gate:
- run a single cheap-mode query only when an explicit opt-in flag is set
- keep query harmless and low-cost
- log model used and citation count, never the API key

## Release gate for v1

Do not call v1 ready until all of these are true:
- wrapper/direct-entrypoint behavior is documented and intentional
- JSON contract is validated in automation
- fallback triggers are tested with stubs or fixtures
- `.env.local` remains ignored
- default smoke tests are non-destructive and quota-free

## Recommended next implementation move

Chosen direction:

1. **Python is the supported entrypoint**
   - `.env.local` loading should happen in Python
   - shell remains a thin convenience wrapper
   - test both, but treat Python as canonical

That choice removes the earlier entrypoint mismatch and should stay stable unless the skill is later promoted into a plugin/tool.


path: references/development-goals-v0.1.1.md
content: # gemini-smart-search vNext goals (target: v0.1.1)

## Goals

- Keep API-key requirements explicit and consistent across skill metadata, runtime behavior, and human-facing docs.
- Make ClawHub artifact packaging deterministic, minimal, and easy to audit.
- Keep repo-only files clearly separated from artifact-shipped files.
- Preserve current runtime behavior, including header-based Gemini auth and env fallback compatibility.

## Non-goals

- No provider expansion beyond Gemini.
- No major JSON contract redesign.
- No canonical citation normalization in this pass.
- No automated release publishing in this pass.

## Prioritized todo

### P0
- Add a concise artifact policy with explicit include/exclude decisions.
- Add a repeatable artifact-prep script that exports only approved files.
- Update release checklist so artifact validation is concrete instead of ad hoc.
- Ensure docs consistently declare `SMART_SEARCH_GEMINI_API_KEY` as primary and `GEMINI_API_KEY` as compatibility fallback.

### P1
- Tighten repo hygiene for generated packaging output.
- Document that `LICENSE` stays in the GitHub repo but is excluded from the ClawHub artifact.
- Add lightweight validation that the prepared artifact excludes `.git/`, `.env.local`, and other repo/dev noise.

### P2
- Consider a non-secret debug field for selected API-key source if future troubleshooting needs it.
- Consider a single-command release helper that can emit a tarball/zip once the artifact format is standardized.

## Artifact policy

### Ship in ClawHub artifact
- `SKILL.md`
- `README.md`
- `scripts/gemini_smart_search.py`
- `scripts/gemini_smart_search.sh`
- `scripts/smoke_test.sh`
- `references/config.md`
- `references/release-checklist.md`
- `assets/example-output.json`

### Keep in repo only
- `.git/`
- `.env.local`
- `.gitignore`
- `LICENSE`
- `references/development-goals-v0.1.1.md`
- QA working notes / release notes / review notes not needed at runtime, including:
  - `references/agent-qa-cases.md`
  - `references/escalation-design.md`
  - `references/model-id-recon.md`
  - `references/qa-results-2026-03-12.md`
  - `references/qa-test-plan.md`
  - `references/release-notes-v0.1.0.md`
  - `references/release-notes-v0.1.1.md`
  - `references/vnext-review-2026-03-12.md`

## Release validation checklist

- Confirm `SKILL.md` metadata still declares `SMART_SEARCH_GEMINI_API_KEY` as primary env.
- Confirm runtime still prefers `SMART_SEARCH_GEMINI_API_KEY` and falls back to `GEMINI_API_KEY`.
- Confirm request auth still uses `x-goog-api-key` header, not URL query params.
- Run `scripts/smoke_test.sh`.
- Run `scripts/prepare_artifact.sh`.
- Inspect exported artifact contents and verify excluded files are absent.
- Confirm no secret-bearing local files are tracked or exported.
- Confirm repo-only `LICENSE` remains present in GitHub source but absent from artifact export.


path: references/config.md
content: # Gemini Smart Search Config Notes

## Environment variables

Preferred API key resolution order:

1. `SMART_SEARCH_GEMINI_API_KEY`
2. `GEMINI_API_KEY`

## Intended model chains

Human-facing display chains:

### cheap
- `gemini-2.5-flash-lite`
- `gemini-3.1-flash-lite`
- `gemini-2.5-flash`

### balanced
- `gemini-2.5-flash`
- `gemini-3-flash`
- `gemini-2.5-flash-lite`

### deep
- `gemini-3-flash`
- `gemini-2.5-flash`
- `gemini-3.1-flash-lite`

API candidate ids:
- `gemini-2.5-flash-lite` → `gemini-2.5-flash-lite`
- `gemini-2.5-flash` → `gemini-2.5-flash`
- `gemini-3-flash` → probe `gemini-3-flash-preview`, then `gemini-3-flash`
- `gemini-3.1-flash-lite` → probe `gemini-3.1-flash-lite-preview`, then `gemini-3.1-flash-lite`

## Notes

- The first implementation should stay Gemini-only.
- Keep the script output JSON-first for orchestration.
- Avoid coupling the script to gateway config.
- If later promoted to a plugin, preserve the same mode names and result schema.
- For quota-free smoke tests, Python supports [REDACTED_SECRET] to disable the Python script's repo-local `.env.local` loading temporarily.
- That skip flag does **not** stop the shell wrapper from sourcing `.env.local` first; use direct Python invocation for missing-key smoke checks or temporarily hide the env file when testing wrapper parity.
- Request auth should be sent via the `x-goog-api-key` header rather than a URL query parameter so the key is less likely to leak into logs or telemetry.
- Registry/skill metadata should declare `SMART_SEARCH_GEMINI_API_KEY` as the primary required env, even though runtime still falls back to `GEMINI_API_KEY` for compatibility.


path: references/release-notes-v0.1.1.md
content: # gemini-smart-search v0.1.1

Artifact-policy and release-hygiene update for the Gemini-only, script-backed smart search skill for OpenClaw.

## What changed in v0.1.1

- Clarified and tightened the ClawHub artifact policy so the export is a deliberate runtime subset of the repo.
- Added `README.md` and `references/release-checklist.md` to the approved artifact ship list.
- Kept `LICENSE` in the GitHub repo while explicitly excluding it from the ClawHub artifact.
- Kept credential policy unchanged and explicit:
  1. `SMART_SEARCH_GEMINI_API_KEY`
  2. `GEMINI_API_KEY`
- Kept the Python entrypoint as the canonical agent entrypoint, with the shell wrapper documented as convenience-only.
- Added deterministic staged-artifact preparation and exclusion checks via `scripts/prepare_artifact.sh`.
- Reconciled release docs so the documented artifact contents match the actual staging script.

## Included files in the v0.1.1 ClawHub artifact

- `SKILL.md`
- `README.md`
- `scripts/gemini_smart_search.py`
- `scripts/gemini_smart_search.sh`
- `scripts/smoke_test.sh`
- `references/config.md`
- `references/release-checklist.md`
- `assets/example-output.json`

## Repo-only / excluded from the v0.1.1 ClawHub artifact

- `.git/`
- `.env.local`
- `.gitignore`
- `LICENSE`
- development, QA, review, and release-note working docs not needed at runtime, including:
  - `references/development-goals-v0.1.1.md`
  - `references/agent-qa-cases.md`
  - `references/escalation-design.md`
  - `references/model-id-recon.md`
  - `references/qa-results-2026-03-12.md`
  - `references/qa-test-plan.md`
  - `references/release-notes-v0.1.0.md`
  - `references/release-notes-v0.1.1.md`
  - `references/vnext-review-2026-03-12.md`

## Validated release behaviors

- Python entrypoint works for live queries.
- Shell wrapper works for live queries.
- `--json` returns structured JSON on success and supported runtime errors.
- Invalid CLI arguments with `--json` return structured JSON on stderr.
- Missing-key behavior is graceful and structured.
- Repo-local `.env.local` is ignored by git.
- Wrapper and Python entrypoints are behaviorally aligned.
- Fallback metadata is present and useful.
- `escalation` field is present on success and error responses.

## Known caveats retained in v0.1.1

- Citation URLs may still be Google/Vertex grounding redirect URLs rather than canonical publisher URLs.
- `model_used` is the actual probed API model id, not the human-facing display label.
- Live fallback means identical queries are not guaranteed to land on the same final model every time.
- Some Gemini 3 display labels map to preview-suffixed API ids in practice.

## Release scope

This release is intended as a clean skill artifact.

Before packaging/publishing the artifact:
- export from the clean staging helper (`scripts/prepare_artifact.sh`) instead of publishing a raw repo snapshot
- do not include `.git/`, `.env.local`, `.gitignore`, or `LICENSE`
- keep `LICENSE` in the GitHub repo; exclude it only from the ClawHub artifact

## Not included in v0.1.1

- canonical citation normalization
- deterministic fallback unit test harness
- automated model-recon refresh tooling
- richer GitHub issue prefill UX for escalations


path: references/release-notes-v0.1.0.md
content: # gemini-smart-search v0.1.0

Initial public artifact release of a Gemini-only, script-backed smart search skill for OpenClaw.

## What it does

- Uses the Gemini Developer API directly instead of relying on gateway-level `web_search` model switching.
- Supports Google Search grounding.
- Supports three routing modes:
  - `cheap`
  - `balanced`
  - `deep`
- Resolves API keys with the following precedence:
  1. `SMART_SEARCH_GEMINI_API_KEY`
  2. `GEMINI_API_KEY`
- Returns structured JSON suitable for orchestration.
- Includes fallback across configured Gemini model candidates for retryable upstream/model failures.
- Includes `escalation` metadata so agents can direct a human to file a GitHub issue for suspicious/systemic failures.

## Included files

- `SKILL.md`
- `scripts/gemini_smart_search.py`
- `scripts/gemini_smart_search.sh`
- `scripts/smoke_test.sh`
- `references/config.md`
- `references/qa-test-plan.md`
- `references/qa-results-2026-03-12.md`
- `references/agent-qa-cases.md`
- `references/model-id-recon.md`
- `references/escalation-design.md`
- `references/vnext-review-2026-03-12.md`
- `assets/example-output.json`

## Validated release behaviors

- Python entrypoint works for live queries.
- Shell wrapper works for live queries.
- `--json` returns structured JSON on success and supported runtime errors.
- Invalid CLI arguments with `--json` return structured JSON on stderr.
- Missing-key behavior is graceful and structured.
- Repo-local `.env.local` is ignored by git.
- Wrapper and Python entrypoints are behaviorally aligned.
- Fallback metadata is present and useful.
- `escalation` field is present on success and error responses.

## Known caveats

- Citation URLs may still be Google/Vertex grounding redirect URLs rather than canonical publisher URLs.
- `model_used` is the actual probed API model id, not the human-facing display label.
- Live fallback means identical queries are not guaranteed to land on the same final model every time.
- Some Gemini 3 display labels map to preview-suffixed API ids in practice.

## Release scope

This release is intended as a clean skill artifact.

Before packaging/publishing the artifact:
- export from the clean staging helper (`scripts/prepare_artifact.sh`) instead of publishing a raw repo snapshot
- do not include `.git/`, `.env.local`, `.gitignore`, or `LICENSE`
- keep `LICENSE` in the GitHub repo; exclude it only from the ClawHub artifact

## Not included in v0.1.0

- canonical citation normalization
- deterministic fallback unit test harness
- automated model-recon refresh tooling
- richer GitHub issue prefill UX for escalations


path: references/model-id-recon.md
content: # Gemini model ID reconnaissance

Date: 2026-03-12
Repo: `skills/gemini-smart-search`
Branch: `dev`

## Goal

Verify which Gemini Developer API model IDs are currently usable for this skill, especially where the Gemini UI / human-facing labels do **not** match the actual callable API model IDs.

## Method

Used the repo-local key path indirectly through the skill repo's local environment:

- sourced `skills/gemini-smart-search/.env.local`
- called `GET https://generativelanguage.googleapis.com/v1beta/models`
- probed candidate IDs with `:generateContent`
- sanity-checked the skill's own Python entrypoint for `cheap` / `balanced` / `deep`

No secrets are recorded here.

---

## 1. Models listed by the Gemini Developer API for this account

Relevant entries returned by `ListModels`:

- `models/gemini-2.5-flash`
- `models/gemini-2.5-flash-lite`
- `models/gemini-flash-latest`
- `models/gemini-flash-lite-latest`
- `models/gemini-3-flash-preview`
- `models/gemini-3.1-flash-lite-preview`
- `models/gemini-3-pro-preview`
- `models/gemini-3.1-pro-preview`

Also listed but less relevant to this skill:

- `models/gemini-2.0-flash`
- `models/gemini-2.0-flash-001`
- `models/gemini-2.0-flash-lite`
- `models/gemini-2.0-flash-lite-001`
- `models/gemini-2.5-flash-lite-preview-09-2025`
- image / tts / audio / embedding variants

---

## 2. Verified callable IDs

These IDs accepted `generateContent` successfully during probing.

### Stable / directly usable

- `gemini-2.5-flash-lite`
- `gemini-2.5-flash`
- `gemini-flash-lite-latest`
- `gemini-flash-latest`

### Preview / directly usable

- `gemini-3-flash-preview`
- `gemini-3.1-flash-lite-preview`

Notes:

- `gemini-3-flash-preview` is the correct callable ID behind the human-facing label **Gemini 3 Flash**.
- `gemini-3.1-flash-lite-preview` is the correct callable ID behind the human-facing label **Gemini 3.1 Flash Lite**.
- `gemini-flash-latest` and `gemini-flash-lite-latest` are valid aliases, but they are moving targets, so they are better as optional aliases than as primary pinned routing IDs.

---

## 3. Rejected / not-callable-as-typed IDs

These either failed `generateContent` or are not appropriate primary targets.

### Not found as raw UI labels

- `gemini-3-flash`
  - Result: `404 NOT_FOUND`
  - Meaning: the UI-style label is **not** the API ID; use `gemini-3-flash-preview` instead.

- `gemini-3.1-flash-lite`
  - Result: `404 NOT_FOUND`
  - Meaning: the UI-style label is **not** the API ID; use `gemini-3.1-flash-lite-preview` instead.

### Listed but not a good current target

- `gemini-2.5-flash-lite-preview-09-2025`
  - Result: `404 NOT_FOUND`
  - Error indicated it is no longer available to new users.
  - Meaning: do **not** use this preview-suffixed historical ID in routing.

---

## 4. Ambiguous / quota-limited during probing

These models are listed by `ListModels`, but live invocation was quota-limited in this session, so they should be considered **listed and likely callable**, not fully verified by successful response here.

- `gemini-3-pro-preview`
  - `ListModels`: present
  - Probe: `429 RESOURCE_EXHAUSTED`

- `gemini-3.1-pro-preview`
  - `ListModels`: present
  - Probe: `429 RESOURCE_EXHAUSTED`

Because the error was quota exhaustion rather than not-found / unsupported, these IDs are probably valid for this account, but this recon task focused on Flash / Flash-Lite routing.

---

## 5. Skill-level routing sanity check

Observed behavior from the current Python skill implementation:

### cheap

Current chain:

- display: `gemini-2.5-flash-lite` → `gemini-3.1-flash-lite` → `gemini-2.5-flash`
- API candidates expand to:
  - `gemini-2.5-flash-lite`
  - `gemini-3.1-flash-lite-preview`
  - `gemini-3.1-flash-lite`
  - `gemini-2.5-flash`

Observed result:

- succeeded immediately on `gemini-2.5-flash-lite`

### balanced

Current chain:

- display: `gemini-2.5-flash` → `gemini-3-flash` → `gemini-2.5-flash-lite`
- API candidates expand to:
  - `gemini-2.5-flash`
  - `gemini-3-flash-preview`
  - `gemini-3-flash`
  - `gemini-2.5-flash-lite`

Observed result:

- succeeded immediately on `gemini-2.5-flash`

### deep

Current chain:

- display: `gemini-3-flash` → `gemini-2.5-flash` → `gemini-3.1-flash-lite`
- API candidates expand to:
  - `gemini-3-flash-preview`
  - `gemini-3-flash`
  - `gemini-2.5-flash`
  - `gemini-3.1-flash-lite-preview`
  - `gemini-3.1-flash-lite`

Observed result:

- one direct probe earlier succeeded on `gemini-3-flash-preview`
- during the full skill run, `deep` failed because of quota exhaustion after prior probes
- non-preview labels again failed as `404 NOT_FOUND`

Interpretation:

- the routing concept is correct
- the preview fallbacks are essential
- the non-preview raw IDs for Gemini 3 Flash and Gemini 3.1 Flash Lite should remain fallback probes only if you want defensive future-proofing, but they are currently dead weight and generate predictable 404s

---

## 6. Recommended mapping updates

## Recommended human-label → API-ID mapping

### cheap

Preferred display chain:

1. `gemini-2.5-flash-lite`
2. `gemini-3.1-flash-lite`
3. `gemini-2.5-flash`

Recommended API candidate mapping:

- `gemini-2.5-flash-lite` → `gemini-2.5-flash-lite`
- `gemini-3.1-flash-lite` → `gemini-3.1-flash-lite-preview`
- `gemini-2.5-flash` → `gemini-2.5-flash`

### balanced

Preferred display chain:

1. `gemini-2.5-flash`
2. `gemini-3-flash`
3. `gemini-2.5-flash-lite`

Recommended API candidate mapping:

- `gemini-2.5-flash` → `gemini-2.5-flash`
- `gemini-3-flash` → `gemini-3-flash-preview`
- `gemini-2.5-flash-lite` → `gemini-2.5-flash-lite`

### deep

If this skill must stay Flash / Flash-Lite only:

1. `gemini-3-flash` → `gemini-3-flash-preview`
2. `gemini-2.5-flash` → `gemini-2.5-flash`
3. `gemini-3.1-flash-lite` → `gemini-3.1-flash-lite-preview`

If the skill later wants a truly stronger `deep` tier and quota/billing permit it, consider a separate design decision to introduce:

- `gemini-3-pro-preview`
- or `gemini-3.1-pro-preview`

But that would be a product decision, not just an ID-correction change.

---

## 7. Concrete recommendations for the codebase

### Keep

- probing `gemini-3-flash-preview` for the display label `gemini-3-flash`
- probing `gemini-3.1-flash-lite-preview` for the display label `gemini-3.1-flash-lite`

### Remove or demote

Strong recommendation: drop these second-stage probes unless you specifically want future API auto-healing:

- `gemini-3-flash`
- `gemini-3.1-flash-lite`

Why:

- they currently produce predictable `404 NOT_FOUND`
- they add latency and noisy failures in fallback logs
- `ListModels` already exposes the preview IDs directly, so the correct IDs are knowable

### Optional aliases

Useful but optional fallback aliases:

- `gemini-flash-latest`
- `gemini-flash-lite-latest`

These should not replace pinned primary IDs unless you explicitly want rolling behavior.

---

## Bottom line

For this account and current Gemini Developer API behavior:

- **Gemini 3 Flash** in UI maps to **`gemini-3-flash-preview`**
- **Gemini 3.1 Flash Lite** in UI maps to **`gemini-3.1-flash-lite-preview`**
- the raw non-preview labels **`gemini-3-flash`** and **`gemini-3.1-flash-lite`** are currently **not callable**
- **`gemini-2.5-flash`** and **`gemini-2.5-flash-lite`** are stable callable IDs
- `*-latest` aliases exist and work, but are optional moving-target aliases rather than the best pinned defaults


path: references/release-checklist.md
content: # Release Checklist

Use this checklist before publishing a `gemini-smart-search` artifact.

Legend:
- `[x]` completed for the current release candidate
- `[ ]` still required before publishing

## A. Core functionality

- [x] Python entrypoint works for live queries.
- [x] Shell wrapper works for live queries.
- [x] Google Search grounding is enabled in the direct Gemini API call path.
- [x] `cheap`, `balanced`, and `deep` modes are implemented.
- [x] Fallback across configured candidate model IDs works for retryable failures.
- [x] Missing-key behavior is graceful.
- [x] `SMART_SEARCH_GEMINI_API_KEY` takes precedence over `GEMINI_API_KEY`.

## B. Contract / output stability

- [x] `--json` returns structured JSON on success.
- [x] `--json` returns structured JSON on supported runtime errors.
- [x] Invalid CLI arguments under `--json` return structured JSON on stderr.
- [x] Output includes `display_chain` and `fallback_chain`.
- [x] Output includes `escalation`.
- [x] `model_used` semantics are documented as actual API model ids.

## C. Documentation consistency

- [x] `SKILL.md` documents Python as the canonical entrypoint.
- [x] Wrapper is documented as convenience-only.
- [x] `python -m gemini_smart_search` is explicitly documented as unsupported for agents.
- [x] Citation redirect-link behavior is documented.
- [x] Escalation behavior is documented.
- [x] Model-ID recon notes are written down.
- [x] QA notes are aligned with current behavior.

## D. QA / validation

- [x] Non-destructive smoke tests pass.
- [x] Missing-key path validated.
- [x] Invalid-argument JSON behavior validated.
- [x] Live cheap-mode path validated.
- [x] Live wrapper path validated.
- [x] Live deep/fallback path validated.
- [x] Agent-style misuse review completed.
- [x] Release-readiness doc audit completed.

## E. Secret / repo hygiene

- [x] `.env.local` is gitignored.
- [x] No API key is stored in tracked files.
- [x] Artifact export policy explicitly excludes `.git/`, `.env.local`, and repo/dev-only files.
- [x] `LICENSE` is kept as a repo-only file and excluded from the ClawHub artifact.

## F. Release packaging

Artifact ship list for ClawHub export:
- `SKILL.md`
- `README.md`
- `scripts/gemini_smart_search.py`
- `scripts/gemini_smart_search.sh`
- `scripts/smoke_test.sh`
- `references/config.md`
- `references/release-checklist.md`
- `assets/example-output.json`

Repo-only / exclude from ClawHub export:
- `.git/`
- `.env.local`
- `.gitignore`
- `LICENSE`
- development, QA, review, and release-note working docs not needed at runtime

Required packaging checks:
- [x] `scripts/prepare_artifact.sh` stages only the approved artifact contents.
- [x] Artifact contents are reviewed after export.
- [x] Release note is published alongside the artifact (`references/release-notes-v0.1.1.md`).
- [x] Version tag/name is chosen for the artifact (`v0.1.1`).

## G. Known acceptable caveats for v0.1.1

These do **not** block the current artifact release:

- [x] Citation URLs are still grounding redirect URLs.
- [x] Canonical citation normalization is deferred.
- [x] Deterministic fallback unit tests are deferred.
- [x] Automated model-recon refresh is deferred.

## Release decision

For a publishable candidate, run `scripts/smoke_test.sh`, then `scripts/prepare_artifact.sh`, then inspect the staged artifact contents.

If every unchecked item in section F is completed, this release candidate is ready to publish as an artifact.


path: references/qa-results-2026-03-12.md
content: # QA Results — 2026-03-12

Focused CLI-level QA for the current `gemini-smart-search` implementation.

## Case matrix

| ID | Area | Invocation | Outcome | Notes |
| --- | --- | --- | --- | --- |
| T01 | JSON contract + env | Python, [REDACTED_SECRET], no keys | PASS | Structured JSON error; `error.type=missing_api_key`; required top-level keys present. |
| T02 | Entrypoint consistency + env | Shell wrapper, `.env.local` temporarily hidden, no keys | PASS | Wrapper returns the same structured missing-key JSON shape as Python. |
| T03 | Env precedence | Python, both key vars set to invalid values | PASS | Behavior indicates `SMART_SEARCH_GEMINI_API_KEY` wins over `GEMINI_API_KEY`; first attempted model returns `INVALID_ARGUMENT`; no fallback on bad local auth. |
| T04 | CLI argument boundary | Python, invalid `--mode bogus --json` | PASS | Exit code `2`; structured JSON error is emitted on stderr with `error.type=invalid_arguments`. |
| T05 | Live cheap-mode contract | Python, cheap live query | PASS | Real call succeeded; `model_used=gemini-2.5-flash-lite`; JSON contract intact. |
| T06 | Wrapper vs Python live parity | Shell wrapper, same cheap live query | PASS | Wrapper and Python both succeed with the same model and equivalent JSON shape. |
| T07 | Citation output shape | Python, cheap live query | PASS with caveat | `citations` is always a list of `{title,url}` objects, but URLs are grounding redirect links (`vertexaisearch.cloud.google.com/...`) rather than canonical source URLs. |
| T08 | Routing metadata + fallback | Python, deep live query | PASS with caveat | Deep mode fell through `gemini-3-flash-preview` and `gemini-3-flash` before succeeding on `gemini-2.5-flash`; `usage.attempted_models` correctly records the route. |
| T09 | Secret file hygiene | `git check-ignore .env.local` | PASS | `.env.local` is ignored and not tracked. |

## Main findings

### Confirmed working

- Python and shell wrapper are consistent for both missing-key and live-query paths.
- JSON contract is stable across success and structured error paths when `--json` is used.
- Local missing-key handling is graceful and traceback-free.
- Fallback does **not** trigger on obvious local auth/config failure (`INVALID_ARGUMENT` bad key), which is the right boundary.
- `usage.attempted_models` is useful and currently accurate for auditing fallback behavior.

### Bugs / risks

1. **Citation URLs are redirect wrappers, not canonical source links**
   - Live citations consistently returned URLs like `https://vertexaisearch.cloud.google.com/grounding-api-redirect/...`.
   - This is valid as a URL field, but poor for downstream consumers expecting stable, human-meaningful source URLs.
   - Risk: link rot, poor UX, harder deduplication, harder post-processing.

2. **Deep-mode preferred Gemini 3 candidates look stale or unavailable in practice**
   - In live testing, `deep` mode attempted `gemini-3-flash-preview`, then `gemini-3-flash`, and only succeeded on `gemini-2.5-flash`.
   - This may reflect quota/model availability drift, but it means the nominal `deep` preference is not what users actually get today.
   - Risk: silent cost/performance mismatch versus documented mode intent.

3. **Error JSON omits explicit selected-key provenance**
   - QA could infer precedence behavior from outcome, but the script does not expose which key source was selected.
   - That keeps secrets safer, but it makes debugging env precedence slightly opaque.
   - Risk: slower diagnosis when both env vars exist and one is stale.

## Recommendations

- Normalize citations toward canonical source URLs if grounding metadata exposes them, or document clearly that current URLs are Google grounding redirects.
- Re-verify real Gemini 3 model IDs against the currently visible API inventory and refresh `MODEL_CANDIDATES` if needed.
- Consider adding a non-secret debug field such as `usage.api_key_source` with values like `SMART_SEARCH_GEMINI_API_KEY` / `GEMINI_API_KEY` only when `--json` is used, if easier env debugging is worth the extra contract surface.


path: README.md
content: # gemini-smart-search

A Gemini-powered smart search skill for OpenClaw.

## For humans

You do **not** need to operate this skill manually.

If your agent needs it, just give the agent this repo URL:

```text
[REDACTED_SECRET]:jas0n1ee/gemini-smart-search.git
```

The agent should:
- clone or fetch the repo
- read `SKILL.md`
- ask you for a Gemini API key if setup is incomplete

This skill requires a Gemini API key:
- primary / preferred: `SMART_SEARCH_GEMINI_API_KEY`
- compatibility fallback: `GEMINI_API_KEY`

Keep the key in a gitignored local env file such as `.env.local`, not in tracked files.

## What it is

This skill gives an agent a Gemini-only search workflow with:
- Google Search grounding
- model routing (`cheap` / `balanced` / `deep`)
- fallback across Gemini model candidates
- structured JSON output for orchestration

## Notes

- This repo is primarily for agent use.
- Human-facing setup is intentionally minimal.
- Release notes and release checklist live under `references/`.
- The ClawHub artifact should be a clean subset of this repo, not a full repo snapshot.
- `LICENSE` stays in the GitHub repo but is intentionally excluded from the ClawHub artifact export.
- `.env.local`, `.git/`, and other repo/dev-only files must never ship in the artifact.


path: scripts/gemini_smart_search.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"

if [ -f "$SKILL_DIR/.env.local" ]; then
  set -a
  # shellcheck disable=SC1090
  . "$SKILL_DIR/.env.local"
  set +a
fi

exec python3 "$SCRIPT_DIR/gemini_smart_search.py" "$@"


path: scripts/smoke_test.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$SKILL_DIR"
WRAPPER="$SCRIPT_DIR/gemini_smart_search.sh"
PYTHON_ENTRY="$SCRIPT_DIR/gemini_smart_search.py"

pass() { printf 'PASS: %s\n' "$1"; }
info() { printf 'INFO: %s\n' "$1"; }
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }

json_assert() {
  local file="$1"
  local mode="$2"
  python3 - "$file" "$mode" <<'PY'
import json, sys
path, expected_mode = sys.argv[1], sys.argv[2]
with open(path, 'r', encoding='utf-8') as f:
    data = json.load(f)
required = ["ok", "query", "mode", "model_used", "fallback_chain", "display_chain", "answer", "citations", "error", "escalation"]
missing = [k for k in required if k not in data]
if missing:
    raise SystemExit(f"missing keys: {missing}")
if data["mode"] != expected_mode:
    raise SystemExit(f"unexpected mode: {data['mode']}")
if not isinstance(data["fallback_chain"], list):
    raise SystemExit("fallback_chain must be a list")
if not isinstance(data["citations"], list):
    raise SystemExit("citations must be a list")
usage = data.get("usage", {})
if usage.get("provider") != "gemini":
    raise SystemExit(f"unexpected provider: {usage.get('provider')}")
if not isinstance(usage.get("grounding"), bool):
    raise SystemExit("usage.grounding must be boolean")
error = data.get("error")
if not isinstance(error, dict):
    raise SystemExit("error must be an object in missing-key smoke mode")
if error.get("type") != "missing_api_key":
    raise SystemExit(f"unexpected error type: {error.get('type')}")
escalation = data.get("escalation")
if not isinstance(escalation, dict):
    raise SystemExit("escalation must be an object")
if escalation.get("should_open_issue") not in (False, None):
    raise SystemExit("missing-key path should not request issue creation")
print("JSON_OK")
PY
}

TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT

for mode in cheap balanced deep; do
  out="$TMP_DIR/$mode.json"
  set +e
  env -i PATH="$PATH" GEMINI_SMART_SEARCH_SKIP_LOCAL_ENV=1 python3 "$PYTHON_ENTRY" --query "smoke test" --mode "$mode" --json > "$out"
  status=$?
  set -e
  if [ "$status" -ne 1 ]; then
    fail "expected missing-key smoke path to exit 1 for mode=$mode; got $status"
  fi
  json_assert "$out" "$mode" >/dev/null
  pass "non-destructive JSON smoke passes for mode=$mode"
done

if git -C "$REPO_DIR" check-ignore .env.local >/dev/null 2>&1; then
  pass ".env.local is gitignored"
else
  fail ".env.local is not ignored"
fi

if [ "${LIVE_SMOKE:-0}" = "1" ]; then
  info "LIVE_SMOKE=1 set; running one live wrapper query"
  live_out="$TMP_DIR/live.json"
  "$WRAPPER" --query "BoundaryML context engineering" --mode cheap --json > "$live_out"
  python3 - "$live_out" <<'PY'
import json, sys
with open(sys.argv[1], 'r', encoding='utf-8') as f:
    data = json.load(f)
if not data.get('ok'):
    raise SystemExit(f"live smoke failed: {data.get('error')}")
if not data.get('model_used'):
    raise SystemExit('live smoke missing model_used')
if not isinstance(data.get('citations'), list):
    raise SystemExit('live smoke citations must be a list')
print('LIVE_OK')
PY
  pass "live wrapper smoke succeeded"
else
  info "Skipped live wrapper query. Set LIVE_SMOKE=1 to verify repo-local env loading and real API path."
fi

printf 'Smoke tests completed successfully.\n'


path: scripts/gemini_smart_search.py
content: #!/usr/bin/env python3
"""Gemini Smart Search.

Minimal Gemini-only smart search worker with model routing and fallback.

Features:
- accepts --query / --mode / --json
- resolves API key from SMART_SEARCH_GEMINI_API_KEY then GEMINI_API_KEY
- tries a model chain based on mode
- uses Google Search grounding via the Gemini API
- falls back on quota / unavailable model / transient upstream errors
- returns standardized JSON for orchestration
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any

DISPLAY_MODEL_CHAINS = {
    "cheap": ["gemini-2.5-flash-lite", "gemini-3.1-flash-lite", "gemini-2.5-flash"],
    "balanced": ["gemini-2.5-flash", "gemini-3-flash", "gemini-2.5-flash-lite"],
    "deep": ["gemini-3-flash", "gemini-2.5-flash", "gemini-3.1-flash-lite"],
}

MODEL_CANDIDATES = {
    "gemini-2.5-flash-lite": ["gemini-2.5-flash-lite"],
    "gemini-2.5-flash": ["gemini-2.5-flash"],
    "gemini-3-flash": ["gemini-3-flash-preview", "gemini-3-flash"],
    "gemini-3.1-flash-lite": ["gemini-3.1-flash-lite-preview", "gemini-3.1-flash-lite"],
}

API_BASE = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
DEFAULT_TIMEOUT_SECONDS = 45
ISSUE_URL = "https://github.com/jas0n1ee/gemini-smart-search/issues/new"


@dataclass
class SearchError(Exception):
    type: str
    message: str
    status: int | None = None
    retryable: bool = False
    raw_status: str | None = None

    def to_dict(self) -> dict[str, Any]:
        data: dict[str, Any] = {
            "type": self.type,
            "message": self.message,
        }
        if self.status is not None:
            data["status"] = self.status
        if self.raw_status:
            data["raw_status"] = self.raw_status
        return data


def load_repo_local_env() -> None:
    if os.environ.get("GEMINI_SMART_SEARCH_SKIP_LOCAL_ENV") == "1":
        return
    script_dir = Path(__file__).resolve().parent
    env_path = script_dir.parent / ".env.local"
    if not env_path.exists():
        return
    for raw_line in env_path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip()
        if not key or key in os.environ:
            continue
        if value and len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
            value = value[1:-1]
        os.environ[key] = value


def resolve_api_key() -> str | None:
    return os.environ.get("SMART_SEARCH_GEMINI_API_KEY") or os.environ.get("GEMINI_API_KEY")


class JsonArgumentParser(argparse.ArgumentParser):
    def error(self, message: str) -> None:
        wants_json = "--json" in sys.argv[1:]
        if wants_json:
            mode = "balanced"
            argv = sys.argv[1:]
            for idx, token in enumerate(argv):
                if token == "--mode" and idx + 1 < len(argv):
                    mode = argv[idx + 1]
                elif token.startswith("--mode="):
                    mode = token.split("=", 1)[1]
            display_chain = DISPLAY_MODEL_CHAINS.get(mode, DISPLAY_MODEL_CHAINS["balanced"])
            fallback_chain = []
            for display_name in display_chain:
                fallback_chain.extend(MODEL_CANDIDATES.get(display_name, [display_name]))
            result = {
                "ok": False,
                "query": None,
                "mode": mode,
                "model_used": None,
                "fallback_chain": fallback_chain,
                "display_chain": display_chain,
                "answer": None,
                "citations": [],
                "usage": {"provider": "gemini", "grounding": True, "attempted_models": []},
                "error": {
                    "type": "invalid_arguments",
                    "message": message,
                },
                "escalation": default_escalation(),
            }
            print(json.dumps(result, ensure_ascii=False, indent=2), file=sys.stderr)
            raise SystemExit(2)
        super().error(message)


def parse_args() -> argparse.Namespace:
    p = JsonArgumentParser(description="Gemini smart search")
    p.add_argument("--query", required=True, help="Search query")
    p.add_argument("--mode", choices=sorted(DISPLAY_MODEL_CHAINS), default="balanced")
    p.add_argument("--json", action="store_true", help="Print JSON output")
    return p.parse_args()


def build_request(query: str) -> dict[str, Any]:
    return {
        "contents": [
            {
                "role": "user",
                "parts": [
                    {
                        "text": (
                            "Answer the user's query using Google Search grounding when helpful. "
                            "Provide a concise synthesis and cite relevant sources in grounding metadata.\n\n"
                            f"Query: {query}"
                        )
                    }
                ],
            }
        ],
        "tools": [{"google_search": {}}],
        "generationConfig": {
            "temperature": 0.2,
            "topP": 0.95,
            "maxOutputTokens": 1024,
        },
    }


def classify_api_error(status: int | None, payload: dict[str, Any] | None, fallback_message: str) -> SearchError:
    error_obj = (payload or {}).get("error") or {}
    raw_status = error_obj.get("status")
    message = error_obj.get("message") or fallback_message

    text_blob = " ".join(
        str(x)
        for x in [message, raw_status]
        if x
    ).lower()

    retryable_statuses = {429, 500, 502, 503, 504}
    retryable_raw_statuses = {
        "resource_exhausted",
        "unavailable",
        "deadline_exceeded",
        "aborted",
        "internal",
    }

    is_model_unavailable = (
        (status in {400, 404})
        and any(token in text_blob for token in ["model", "not found", "unsupported", "not available", "not supported"])
    )

    if is_model_unavailable:
        return SearchError(
            type="model_unavailable",
            message=message,
            status=status,
            retryable=True,
            raw_status=raw_status,
        )

    if status in retryable_statuses or (raw_status or "").lower() in retryable_raw_statuses:
        err_type = "quota_exceeded" if status == 429 or "resource_exhausted" in text_blob else "transient_upstream"
        return SearchError(
            type=err_type,
            message=message,
            status=status,
            retryable=True,
            raw_status=raw_status,
        )

    return SearchError(
        type="api_error",
        message=message,
        status=status,
        retryable=False,
        raw_status=raw_status,
    )


def extract_text(candidate: dict[str, Any]) -> str:
    parts = (((candidate.get("content") or {}).get("parts")) or [])
    texts = [part.get("text", "") for part in parts if isinstance(part, dict) and part.get("text")]
    return "\n".join(texts).strip()


def extract_citations(data: dict[str, Any]) -> list[dict[str, str]]:
    citations: list[dict[str, str]] = []
    seen: set[tuple[str, str]] = set()

    candidates = data.get("candidates") or []
    for candidate in candidates:
        grounding = candidate.get("groundingMetadata") or candidate.get("grounding_metadata") or {}
        chunks = grounding.get("groundingChunks") or grounding.get("grounding_chunks") or []
        for chunk in chunks:
            web = chunk.get("web") or {}
            title = (web.get("title") or "").strip()
            url = (web.get("uri") or web.get("url") or "").strip()
            if not url:
                continue
            key = (title, url)
            if key in seen:
                continue
            seen.add(key)
            citations.append({"title": title or url, "url": url})
    return citations


def call_gemini(model: str, query: str, api_key: str) -> dict[str, Any]:
    url = API_BASE.format(model=urllib.parse.quote(model, safe=""))

    body = json.dumps(build_request(query)).encode("utf-8")
    request = urllib.request.Request(
        url,
        data=body,
        headers={
            "Content-Type": "application/json",
            "x-goog-api-key": api_key,
        },
        method="POST",
    )

    try:
        with urllib.request.urlopen(request, timeout=DEFAULT_TIMEOUT_SECONDS) as response:
            payload = response.read().decode("utf-8")
            return json.loads(payload)
    except urllib.error.HTTPError as exc:
        raw = exc.read().decode("utf-8", errors="replace")
        payload = None
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError:
            payload = None
        raise classify_api_error(exc.code, payload, raw or f"HTTP {exc.code}") from None
    except urllib.error.URLError as exc:
        raise SearchError(
            type="network_error",
            message=str(exc.reason),
            retryable=True,
        ) from None
    except TimeoutError:
        raise SearchError(
            type="network_timeout",
            message="Request timed out",
            retryable=True,
        ) from None


def expand_model_chain(mode: str) -> list[str]:
    chain: list[str] = []
    for display_name in DISPLAY_MODEL_CHAINS[mode]:
        chain.extend(MODEL_CANDIDATES.get(display_name, [display_name]))
    return chain


def default_escalation() -> dict[str, Any]:
    return {
        "should_open_issue": False,
        "issue_url": None,
        "reason": None,
        "kind": None,
        "recommended_details": [],
    }


def build_escalation(error_type: str, reason: str | None = None) -> dict[str, Any]:
    mapping = {
        "schema_mismatch": "api-compat",
        "unexpected_response_shape": "api-compat",
        "all_models_failed": "model-routing",
        "model_unavailable": "model-routing",
        "internal_error": "bug",
        "contract_drift": "docs-mismatch",
    }
    kind = mapping.get(error_type)
    if not kind:
        return default_escalation()
    return {
        "should_open_issue": True,
        "issue_url": ISSUE_URL,
        "reason": reason or error_type,
        "kind": kind,
        "recommended_details": [
            "query",
            "mode",
            "display_chain",
            "fallback_chain",
            "model_used",
            "usage.attempted_models",
            "error.type",
            "error.message",
        ],
    }


def run_search(query: str, mode: str, api_key: str) -> dict[str, Any]:
    display_chain = DISPLAY_MODEL_CHAINS[mode]
    fallback_chain = expand_model_chain(mode)
    errors: list[dict[str, Any]] = []

    for model in fallback_chain:
        try:
            data = call_gemini(model=model, query=query, [REDACTED_SECRET])
            candidates = data.get("candidates") or []
            answer = extract_text(candidates[0]) if candidates else ""
            citations = extract_citations(data)
            if not answer:
                raise SearchError(
                    type="empty_response",
                    message="Gemini returned no answer text.",
                    retryable=False,
                )
            return {
                "ok": True,
                "query": query,
                "mode": mode,
                "model_used": model,
                "fallback_chain": fallback_chain,
                "display_chain": display_chain,
                "answer": answer,
                "citations": citations,
                "usage": {
                    "provider": "gemini",
                    "grounding": True,
                    "attempted_models": [entry["model"] for entry in errors] + [model],
                },
                "error": None,
                "escalation": default_escalation(),
            }
        except SearchError as exc:
            errors.append({"model": model, **exc.to_dict()})
            if exc.retryable:
                continue
            return {
                "ok": False,
                "query": query,
                "mode": mode,
                "model_used": None,
                "fallback_chain": fallback_chain,
                "display_chain": display_chain,
                "answer": None,
                "citations": [],
                "usage": {
                    "provider": "gemini",
                    "grounding": True,
                    "attempted_models": [entry["model"] for entry in errors],
                },
                "error": {
                    **exc.to_dict(),
                    "attempts": errors,
                },
                "escalation": build_escalation(exc.type, exc.message),
            }

    final_error = SearchError(
        type="all_models_failed",
        message="All models in the fallback chain failed.",
        retryable=True,
    )
    return {
        "ok": False,
        "query": query,
        "mode": mode,
        "model_used": None,
        "fallback_chain": fallback_chain,
        "display_chain": display_chain,
        "answer": None,
        "citations": [],
        "usage": {
            "provider": "gemini",
            "grounding": True,
            "attempted_models": [entry["model"] for entry in errors],
        },
        "error": {
            **final_error.to_dict(),
            "attempts": errors,
        },
        "escalation": build_escalation(final_error.type, final_error.message),
    }


def print_human(result: dict[str, Any]) -> None:
    if result["ok"]:
        print(result["answer"])
        if result["citations"]:
            print("\nSources:")
            for citation in result["citations"]:
                print(f"- {citation['title']}: {citation['url']}")
    else:
        error = result.get("error") or {}
        print(error.get("message") or "Search failed")


def main() -> int:
    args = parse_args()
    load_repo_local_env()
    [REDACTED_SECRET])

    if not [REDACTED_SECRET] = {
            "ok": False,
            "query": args.query,
            "mode": args.mode,
            "model_used": None,
            "fallback_chain": expand_model_chain(args.mode),
            "display_chain": DISPLAY_MODEL_CHAINS[args.mode],
            "answer": None,
            "citations": [],
            "usage": {"provider": "gemini", "grounding": True, "attempted_models": []},
            "error": {
                "type": "missing_api_key",
                "message": "Missing Gemini API key. Set SMART_SEARCH_GEMINI_API_KEY or GEMINI_API_KEY.",
            },
            "escalation": default_escalation(),
        }
        if args.json:
            print(json.dumps(result, ensure_ascii=False, indent=2))
        else:
            print(result["error"]["message"], file=sys.stderr)
        return 1

    try:
        result = run_search(query=args.query, mode=args.mode, [REDACTED_SECRET])
    except Exception as exc:  # defensive final boundary
        result = {
            "ok": False,
            "query": args.query,
            "mode": args.mode,
            "model_used": None,
            "fallback_chain": expand_model_chain(args.mode),
            "display_chain": DISPLAY_MODEL_CHAINS[args.mode],
            "answer": None,
            "citations": [],
            "usage": {"provider": "gemini", "grounding": True, "attempted_models": []},
            "error": {
                "type": "internal_error",
                "message": str(exc) or exc.__class__.__name__,
            },
            "escalation": build_escalation("internal_error", str(exc) or exc.__class__.__name__),
        }
    if args.json:
        print(json.dumps(result, ensure_ascii=False, indent=2))
    else:
        print_human(result)
    return 0 if result["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/prepare_artifact.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
OUT_DIR="${1:-$SKILL_DIR/.artifact-out}"
STAGE_DIR="$OUT_DIR/gemini-smart-search"

INCLUDE_FILES=(
  "SKILL.md"
  "README.md"
  "scripts/gemini_smart_search.py"
  "scripts/gemini_smart_search.sh"
  "scripts/smoke_test.sh"
  "references/config.md"
  "references/release-checklist.md"
  "assets/example-output.json"
)

info() { printf 'INFO: %s\n' "$1"; }
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }

rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR"

for rel in "${INCLUDE_FILES[@]}"; do
  src="$SKILL_DIR/$rel"
  dst="$STAGE_DIR/$rel"
  [ -f "$src" ] || fail "missing include file: $rel"
  mkdir -p "$(dirname -- "$dst")"
  cp "$src" "$dst"
done

# Validate excluded repo/dev files are not present in the staged artifact.
EXCLUDED_PATHS=(
  ".git"
  ".env.local"
  ".gitignore"
  "LICENSE"
  "references/development-goals-v0.1.1.md"
  "references/agent-qa-cases.md"
  "references/escalation-design.md"
  "references/model-id-recon.md"
  "references/qa-results-2026-03-12.md"
  "references/qa-test-plan.md"
  "references/release-notes-v0.1.0.md"
  "references/release-notes-v0.1.1.md"
  "references/vnext-review-2026-03-12.md"
)

for rel in "${EXCLUDED_PATHS[@]}"; do
  if [ -e "$STAGE_DIR/$rel" ]; then
    fail "excluded path leaked into artifact: $rel"
  fi
done

info "Prepared staged artifact at: $STAGE_DIR"
info "Included files:"
for rel in "${INCLUDE_FILES[@]}"; do
  printf ' - %s\n' "$rel"
done


path: assets/example-output.json
content: {
  "ok": true,
  "query": "BoundaryML context engineering",
  "mode": "cheap",
  "model_used": "gemini-2.5-flash-lite",
  "fallback_chain": [
    "gemini-2.5-flash-lite"
  ],
  "display_chain": [
    "gemini-2.5-flash-lite",
    "gemini-3.1-flash-lite",
    "gemini-2.5-flash"
  ],
  "answer": "BoundaryML publishes high-signal content around AI coding and context engineering.",
  "citations": [
    {
      "title": "BoundaryML - YouTube",
      "url": "https://www.youtube.com/@boundaryml"
    }
  ],
  "usage": {
    "provider": "gemini",
    "grounding": true
  },
  "error": null,
  "escalation": {
    "should_open_issue": false,
    "issue_url": null,
    "reason": null,
    "kind": null,
    "recommended_details": []
  }
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)