# Furoshiki Plugin System — Specification

**Version:** 1.2 · **Status:** Authoritative for runtime `PLUGIN.md` plugins, hook contracts, environment variables, and operator CLI.

This document defines how **user-installed plugins** extend Furoshiki: they live under **`$FUROSHIKI_DATA/plugins/`** (default `~/.furoshiki/plugins/`), declare hooks in **`PLUGIN.md`**, and run as **subprocesses** with a stable contract. They are **not** part of the git source tree.

**Companion docs**

| Topic | Document |
|-------|----------|
| Harness contract (`session-context.json`, `write_session.py`) | `docs/INTEGRATION.md` |
| Agent / IDE integration (background scripts, levels) | `docs/AGENT-INTEGRATION.md` |
| Delegation, worktrees, skill **proposals**; self-mod **test examples** (Telegram / CLI) | `docs/DELEGATION-AND-SKILLS.md` |
| Unified **extension** queue (`extension_work_items`, dashboard) | `docs/EXTENSIONS-UNIFIED-PIPELINE.md` |
| Full architecture | `docs/SELF-AWARENESS-DESIGN.md` |
| Human-friendly overview | `https://furoshiki.ai/plugins-extensions.html` (static HTML in [furoshiki-site](https://github.com/bvelasquez/furoshiki-site)) |
| Plugin HTTP micro-API (`/api/v1/plugin/*`) | `docs/PLUGIN-API.md` |
| Plugin author's how-to guide | `docs/PLUGIN-HOW-TO.md` |
| Brainstormed plugin concepts (not normative) | `docs/PLUGIN-IDEAS.md` |

---

## Vocabulary — plugins, Brain, extensions

| Term | Meaning |
|------|---------|
| **Brain** | Process `scripts/brain.py`: runs `scripts/scheduler.py` against **`$FUROSHIKI_DATA/memory/schedules.json`**, manages long-running children (repair API + Telegram listener), writes heartbeat. Replaces OS cron for Furoshiki work. |
| **Runtime plugin** | Directory `$FUROSHIKI_DATA/plugins/<name>/` with **`PLUGIN.md`** + **`scripts/`**. Loaded by **`plugin_loader.py`**. Does not require editing the core repo. |
| **Extension (operator)** | **Delegation jobs**, **repair jobs**, and **skill proposals** tracked in **`extension_work_items`** — CLI **`furoshiki extensions`**, dashboard **Extensions**. This is *agentic / repair* work, separate from dropping a plugin folder. |
| **Callable skill** | A **`type: callable`** hook: a named tool the LLM can invoke (Telegram tool loop, `skill_orchestrator`, etc.). |
| **`type: cron` in PLUGIN.md** | Declares a **Brain-scheduled** job (UTC cron expression). **`furoshiki plugins reload`** syncs these into **`schedules.json`** as tasks with id prefix **`plugin__`** (see [Scheduled hooks](#cron-brain-scheduler)). |

---

## For LLM authors — rules of the road

Use this checklist when **creating or editing** plugin files (including self-modification drafts):

1. **Directory name = `name:`** in YAML (slug, lowercase, **prefer** hyphens / kebab-case). You may also use `_.` in the **directory** and matching `name:` if you keep them identical — the same segment is used in REST paths and the dashboard.  
2. **Hooks = separate Python files** under `scripts/`. Each must handle the **documented `--mode`** and arguments for that hook type (except **scheduled** / legacy `cron` type — those run **standalone** via `run_plugin_scheduled.py`).  
3. **Never import** `telegram_listener`, `soul_engine`, `voice_dispatcher`, `delegation_dispatcher`, `repair_dispatcher`, `self_modification_dispatcher`.  
4. **Never write** `config/*`, `memory/*`, `.env`, or identity files directly. Use the **Plugin API** (`/api/v1/plugin/*` via `furoshiki_plugin_sdk`) for data reads and `mind_queue` writes.  
5. **Never import** `db`, `llm`, or other core Furoshiki modules — `scripts/` is not on `PYTHONPATH`. Use `furoshiki_plugin_sdk` instead.  
6. **Declare all permissions** in YAML; self-modification review enforces alignment and the static pre-screen blocks direct DB access patterns.  
7. **Cron expressions** for `type: cron` are **five-field, UTC**, same as core `schedules.json` tasks.  
8. **Reserved task ids:** do not add manual Brain tasks with id prefix **`plugin__`** — `furoshiki plugins reload` owns that namespace.  
9. **No personal names** in code identifiers (project rule — use generic terms).  
10. **Output JSON** where specified must be **single JSON object on stdout** (pre/post conversation, pre/callable). Invalid JSON → hook skipped.  
11. **Proactive Telegram** to the user only via **`mind_queue`** → **`voice_dispatcher`** (never send Telegram from a plugin subprocess directly).

---

## Design principles

1. **Plugins are guests, not load-bearing walls.** A failing plugin is logged and skipped — it never blocks reflection, conversation, or the Brain scheduler.
2. **Read the world, don't own it.** State snapshots and files passed to plugins are **read-oriented**; mutations go through sanctioned APIs (`db`, events, mind queue).
3. **Declared intent.** The manifest states behavior and permissions; self-modification **review** checks consistency.
4. **User activates; the system may propose.** Self-modification can draft plugins to **`$FUROSHIKI_DATA/drafts/`** and proposals to **`proposals/`**; **`furoshiki plugins apply`** installs an approved plugin into **`plugins/`**.
5. **No core repo pollution.** Shipping a capability as a plugin keeps **`FUROSHIKI_SRC`** clean; optional **`examples/plugins/`** in the repo are templates only.

---

## Operator observability (logs)

- **Process logs (grep):** `plugin_loader` emits `plugin_pipeline step=…` at `INFO` for discovery, hook phases, subprocess `invoke_begin` / `invoke_end`, and callable `callable_begin` / `callable_end` (including unknown-skill attempts with `registered_skills`).
- **Dashboard Extensions tab:** the same pipeline steps enqueue **`log_store.emit_line`** with **`source=extensions`** and JSON **`context`** (`step` plus fields). Use this stream to confirm hooks ran, which plugin slug executed, durations, and outcomes. **`discover_callables`** is throttled (identical manifest signature at most once per 90 seconds) so high-frequency callers do not flood the DB.
- **Dashboard Logs → `plugins` tab / `furoshiki logs plugins`:** lines from **`POST /api/v1/plugin/log`** (and **`PluginClient.log`**) are stored with **`source=plugin:<slug>`** — a dedicated stream so operator-facing plugin diagnostics are not mixed with core services.
- **Skill orchestrator:** `skill_orchestrator` emits `extensions` lines when it starts **plan-and-invoke** (tool list) and when it runs **`execute_skill_calls`** (batch of requested names).

---

## Directory layout

**Runtime (operator data):**

```
$FUROSHIKI_DATA/
  plugins/
    <plugin-name>/
      PLUGIN.md              ← YAML frontmatter + prose (required)
      scripts/
        <entry>.py           ← one or more hook entrypoints (required per hook)
      data/                  ← optional persistent storage for this plugin
      references/            ← optional docs for the operator
  memory/
    schedules.json           ← Brain: core tasks + merged plugin__* tasks
  proposals/                 ← skill/delegation proposal markdown (extensions track)
  drafts/                    ← self-modification staging (not live until applied)
```

**Source tree (not a second plugin home):** the checkout may include **`furoshiki/examples/plugins/`** with **curated** reference copies used for tests and documentation. Personal plugins are **not** committed there; they live only under **`$FUROSHIKI_DATA/plugins/`** (see **`docs/PLUGIN-HOW-TO.md`** and **`examples/plugins/README.md`**).

---

## Hook → Furoshiki caller (ground truth)

| Hook type | Invoked from (representative) |
|-----------|--------------------------------|
| `cron` (Brain) | Brain scheduler runs **`scripts/run_plugin_scheduled.py`** → your `entry` script |
| `pre_conversation` | **`telegram_listener.build_system_prompt()`** → `run_pre_conversation()` (on-demand per conversation) |
| `post_conversation` | **`run_post_conversation.py`** → `run_post_conversation()` |
| `pre_reflection` | **`run_deep_reflection.py`**, **`run_inner_monologue.py`**, **`run_morning_reflection.py`**, **`run_afternoon_processing.py`** → `run_pre_reflection(reflection_type, ...)` |
| `post_reflection` | Same four + outputs → `run_post_reflection(...)` |
| `callable` | **`telegram_listener.py`** + **`llm.py`** tool loop; **`skill_orchestrator.py`**; contemplation / deep reflection paths; **`curiosity_triage.py`** (manifest presence) |
| `post_inbound_signals` | **`telegram_listener.generate_reply`** → **`plugin_loader.run_post_inbound_signals()`** → **`conversation_intent.finalize_conversation_mode_and_extensions()`** (after core `conversation_mode` classification) |
| `soul_engine` | **`soul_engine.py`** tick → **`plugin_loader.run_soul_engine_hook(target, payload)`** at specific tick decision points. Defined `target` values: `profile_gap`. See [soul_engine tick events](#soul_engine-tick-events) below. |

---

## Plugin manifest — `PLUGIN.md`

Every plugin has a `PLUGIN.md` with **YAML frontmatter** followed by human-readable documentation.

### Full schema (template)

```yaml
---
name: <slug>                      # must match directory name
version: 1.0.0
description: >
  One paragraph — what this plugin does and why.
author: user                      # or "Furoshiki (self-generated …)"
active: true                      # false = installed but disabled
hooks:
  - type: <hook-type>             # pre_conversation | post_conversation | pre_reflection | post_reflection | callable | cron | post_inbound_signals | soul_engine
    entry: scripts/<file>.py
    # per-hook fields — see each hook section
permissions:
  read_user_facts: false          # semantic search over user_facts via Plugin API
  read_memories: false            # semantic search over memories via Plugin API (GET /memories-search)
  output_mind_queue: false        # insert into mind_queue via API or mediated stdout
  llm: false                      # LLM calls via Plugin API (budget enforced)
  network: false                  # outbound network from plugin subprocess
  openrouter_web: false           # subprocess may opt into OpenRouter :online via LLM endpoint
settings_schema:                  # optional — exposes config fields in Dashboard (Work & extensions → Plugins)
  - key: my_setting
    type: integer                 # string | integer | boolean | string_list | select | multi_select | text
    label: "Human-readable label"
    description: "Shown as helper text in the dashboard"
    default: 10
    min: 1                        # integer only
    max: 100                      # integer only
    # options: [a, b, c]          # select / multi_select only
---

# Title

Prose for humans: behavior, setup, env vars, troubleshooting.
```

### Required fields

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Must match directory name (slug). |
| `version` | string | Semver. |
| `description` | string | Shown in `furoshiki plugins list`. |
| `active` | bool | `false` disables without uninstalling. |
| `hooks` | list | At least one hook. |
| `permissions` | object | List all core booleans; `openrouter_web` optional (default false). |
| `settings_schema` | list | Optional. Each entry is a field descriptor (see below). Renders a settings form in Dashboard (**Work & extensions → Plugins**). |

### `settings_schema` — dashboard-configurable plugin settings

Declare `settings_schema` in the manifest to expose plugin settings via the dashboard UI. Settings are persisted to `~/.furoshiki/plugins/<name>/settings.json` and readable from plugin scripts via `furoshiki_plugin_sdk.get_settings()`.

**Field descriptor keys:**

| Key | Required | Description |
|-----|----------|-------------|
| `key` | yes | Setting name (used as JSON key in settings.json) |
| `type` | yes | `string`, `integer`, `boolean`, `string_list`, `select`, `multi_select`, `password`, `text` |
| `label` | yes | Human-readable label shown in the dashboard |
| `description` | no | Helper text below the input |
| `default` | no | Default value (used when settings.json has no entry for this key) |
| `min` | no | Minimum value (integer only) |
| `max` | no | Maximum value (integer only) |
| `options` | no | List of allowed values (select / multi_select only) |

**`password` fields:** Renders a masked input. Saving an **empty** value leaves the existing stored secret unchanged. Values are **redacted** in `GET /api/v1/plugins/{name}/settings` responses (never returned to the browser).

**Reading settings in a plugin script:**

```python
import sys, os
# No Furoshiki imports needed — use the SDK or read directly
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

# Option A: SDK helper (preferred)
from furoshiki_plugin_sdk import get_settings
cfg = get_settings()
my_value = cfg.get('my_setting', 10)  # falls back to default

# Option B: direct read (if SDK not on path)
data_dir = os.environ.get('FUROSHIKI_DATA', os.path.expanduser('~/.furoshiki'))
plugin_name = os.environ.get('FUROSHIKI_PLUGIN_NAME', 'my-plugin')
import json
try:
    with open(os.path.join(data_dir, 'plugins', plugin_name, 'settings.json')) as f:
        cfg = json.load(f)
except FileNotFoundError:
    cfg = {}
my_value = cfg.get('my_setting', 10)
```

**Dashboard API (for operator tooling):**

```
GET  /api/v1/plugins                          — list installed plugins
GET  /api/v1/plugins/spec                     — full ``docs/PLUGIN-SPEC.md`` text (dashboard copy for coding agents)
POST /api/v1/plugins/install                  — install from ``.zip`` upload (admin; ``?replace=true`` to overwrite)
GET  /api/v1/plugins/{name}/settings-schema   — field descriptor list
GET  /api/v1/plugins/{name}/settings          — current effective values
PUT  /api/v1/plugins/{name}/settings          — write (admin scope)
GET  /api/v1/plugins/{name}/plugin-md         — raw ``PLUGIN.md`` text (read scope; dashboard editor)
PUT  /api/v1/plugins/{name}/plugin-md         — replace ``PLUGIN.md`` (admin scope; YAML + manifest parse check; runs ``extensions plugins reload``)
```

---

## Hook types

### `cron` (Brain scheduler)

Legacy YAML key **`type: cron`** means: register a **Brain** task, **not** OS crontab.

```yaml
- type: cron
  schedule: "0 9 * * 0"     # UTC, five fields — minute hour day-of-month month day-of-week
  entry: scripts/weekly.py
  timeout: 120               # seconds (mapped to task timeout; default 120)
  description: "Weekly summary every Sunday 09:00 UTC"
```

**Behavior**

- **`furoshiki plugins reload`** reads all **active** plugins, removes **`schedules.json`** tasks whose `id` starts with **`plugin__`**, then appends one task per `cron` hook. Tasks use script **`run_plugin_scheduled.py`** with args `--plugin <directory-name>` `--entry <path>`.
- Brain **hot-reloads** `schedules.json` on change (tick ~10s). **No** `crontab` modification.
- Entry script runs **standalone** (no `--mode`). Environment matches other hooks: **`build_plugin_subprocess_env`** (`FUROSHIKI_ROOT` = source tree, `FUROSHIKI_PLUGIN_*`, `PYTHONPATH`).
- Default log file for merged tasks: **`plugins-scheduled.log`** under **`$FUROSHIKI_DATA/logs/`**.

**Migration:** Older installs may have merged plugin lines into **user crontab**. Current Furoshiki does **not** install plugin lines into crontab; remove duplicates manually if you still have them.

---

### `post_conversation`

Runs after built-in post-conversation processing.

```yaml
- type: post_conversation
  entry: scripts/post_conv.py
  timeout: 60
  description: "…"
```

**Invocation**

```text
python3 scripts/post_conv.py \
  --mode post_conversation \
  --state-json <tmp> \
  --session-json <tmp>
```

**`session-json`** includes summary, quality scores, flags — see implementation in **`run_post_conversation.py`**.

**Output:** none required (stdout ignored). Side effects only.

---

### `post_inbound_signals` (post-classifier policy)

Runs **after** the core inbound classifier has produced a base **`conversation_mode`** (MODEL_MICRO + `conversation_intent` heuristics). Lets plugins adjust **mode** and/or append **extra prompt text** for this turn. **Strict merge** applies in core code *after* your stdout is parsed (see below) — you cannot override **`chat_recall`** away when the user is clearly asking for Telegram / thread transcript recall.

**Operator alternative (no plugin):** the same merge path reads **`memory/operator_settings.json`** → **`conversation_intent`** (see **Operator JSON** below). Plugins run first; operator **`prompt_additions`** are appended **last** (after plugin `prompt_addendum` strings).

```yaml
- type: post_inbound_signals
  entry: scripts/post_inbound_policy.py
  timeout: 8
  priority: 40
  description: "Tune conversation_mode or add ACTIVE CONTEXT lines after the classifier"
```

**Invocation**

```text
python3 scripts/post_inbound_policy.py \
  --mode post_inbound_signals \
  --payload-json <tmp> \
  --state-json <tmp>
```

**`payload-json`** (object):

| Field | Type | Meaning |
|-------|------|---------|
| `version` | int | `1` for the current schema |
| `base_mode` | string | Mode after core classifier (`neutral`, `chat_recall`, `emotional`, …) |
| `user_message` | string | Truncated copy of the user message (length-capped in core) |
| `turn_signals` | object | Serializable subset of inbound TurnSignals (`needs_web`, `closing_ack_only`, `conversation_mode`, …) |

**`state-json`** is the same operator snapshot as other hooks (`plugin_loader._build_state_snapshot()`).

**Stdout (JSON object):**

| Field | Type | Meaning |
|-------|------|---------|
| `conversation_mode` | string or omitted | Optional override; normalized to the same allowed set as core; **last plugin hook wins** among plugins (by priority order). |
| `prompt_addendum` | string or omitted | Optional extra text appended to the system prompt for this turn (concatenated from all plugins, in hook order). Max **4000** characters per plugin; longer output is truncated. |

Omit a field or use `null` to mean “no change” for that field.

**Strict merge (core, not configurable):**

1. Start from **`base_mode`** in the payload.
2. Apply plugin **`conversation_mode`** overrides (last plugin wins).
3. **Clamp:** if the core classifier **or** offline heuristics say **`chat_recall`**, final mode is **`chat_recall`** (plugins cannot move the turn away from transcript-recall safety).
4. Append **`prompt_addendum`** from plugin(s), then operator **`conversation_intent.prompt_additions[final_mode]`** from **`operator_settings.json`**.

Invalid JSON, non‑object stdout, or crashes → that plugin is skipped; others still run.

**Operator JSON** (`memory/operator_settings.json`):

```json
{
  "conversation_intent": {
    "enabled": true,
    "plugins_enabled": true,
    "prompt_additions": {
      "meta": "Extra line for meta turns (example).",
      "emotional": ""
    }
  }
}
```

| Key | Meaning |
|-----|---------|
| `enabled` | `false` disables the whole extension path (same as core `resolve_conversation_mode` only — no plugin subprocess, no operator addenda). |
| `plugins_enabled` | `false` skips **`post_inbound_signals`** plugins only; operator **`prompt_additions`** still apply when `enabled` is true. |
| `prompt_additions` | Map of **allowed mode name → string** (max 4000 chars per value). Keys must be valid `conversation_mode` names. |

Env fallback: **`FUROSHIKI_CONVERSATION_INTENT_EXTENSIONS`** (`0`/`false` = same as `enabled: false`). Prefer Dashboard / JSON when the key is present.

Tier‑1 snapshot and API merge: **`operator_tier1.get_conversation_intent_settings()`**, **`merge_tier1_into_base()`** under key **`conversation_intent`**.

---

### `pre_conversation`

Runs **on-demand** during **`telegram_listener.build_system_prompt()`** when a conversation starts. **No longer** called during periodic `refresh_session_context.py` cron runs. **Stdout** must be one JSON object if contributing context.

```yaml
- type: pre_conversation
  entry: scripts/pre_conv.py
  timeout: 30
  description: "…"
```

**Invocation**

```text
python3 scripts/pre_conv.py --mode pre_conversation --state-json <tmp>
```

**Stdout (JSON):**

```json
{
  "label": "Short title",
  "content": "Plain text for the model",
  "priority": "low"
}
```

Merged into **`session-context.json`** / narrator pipeline as **`plugin_contexts`**.

---

### `pre_reflection`

Runs before the reflection LLM call for a given **`target`**.

```yaml
- type: pre_reflection
  target: deep                  # deep | inner_monologue | morning | afternoon | all
  entry: scripts/pre_reflect.py
  timeout: 60
  priority: 50                  # lower = earlier among plugins
  description: "…"
```

**Invocation**

```text
python3 scripts/pre_reflect.py \
  --mode pre_reflection \
  --reflection-type <target> \
  --state-json <tmp> \
  --prior-context-json <tmp>
```

**Stdout (JSON):** include **`content`**, optional **`label`**, **`priority`** (`low` | `normal` | `high`), **`suggested_questions`** (list). Formatted via **`format_plugin_contexts_for_prompt`**.

---

### `callable`

LLM-invoked tools (names must be **globally unique** across all active plugins).

```yaml
- type: callable
  name: my_tool_id               # unique
  entry: scripts/tool.py
  timeout: 30
  description: "What the tool returns"
  when_to_use: "When to choose this tool"
  args_schema:                   # optional JSON Schema
    type: object
    properties: {}
  resources:                     # optional — declare resources this tool touches
    - name: gmail                # core resource name (see config/defaults/resource_registry.json)
      mode: write                # 'read' or 'write'; only write-mode resources acquire locks
    # plugin-specific resources: use "plugin:<plugin_id>:<resource_name>"
  requires_approval: false       # set true for irreversible actions (gmail send, file delete)
```

**Resource declaration** is validated by `resource_registry.py` at plugin load time. Invalid resource names or cross-namespace violations cause the **entire plugin to be rejected** (logged as a warning; no partial loading).

**`requires_approval: true`** injects the `request_approval` meta-tool into the callable's tool list. When the model calls it, the agent task transitions to `awaiting_approval` and voice_dispatcher surfaces the request to the user. The user replies yes/no in Telegram; Furoshiki continues or cancels the task.

---

### Plugin-level `max_priority`

Plugins that emit `mind_queue` items (via `output_mind_queue: true`) can declare the maximum priority they are allowed to use.

```yaml
max_priority: high         # default — plugin items are high at most
```

Valid values (from most to least powerful):

| Value | DND | All other gates | Dispatch latency |
|---|---|---|---|
| `bypass` | **Ignored** | **All skipped** | **Immediate** — `outreach_trigger.fire_now()` spawns an extra pulse within seconds of insert |
| `critical` | Respected | All skipped | ≤ 5 min (next scheduled pulse) |
| `urgent` | Respected | Standard gates apply | ≤ 5 min |
| `high` | Respected | Standard gates apply | ≤ 5 min |
| `normal` | Respected | Standard gates apply | varies |
| `low` | Respected | Standard gates apply | varies |

**Enforcement:**
- The plugin may not emit a priority higher than its declared `max_priority`.
- The operator can globally cap plugin max_priority via `plugins.max_priority_cap` in `memory/operator_settings.json` (default: `critical` — allows plugins to reach `critical` but not `bypass` unless the operator explicitly sets `bypass`).
- Both caps are applied; the more restrictive wins.
- `bypass` and `critical` items are **never expired by topic dedup** or the 24-hour background sweep. They are only marked `sent` after a successful Telegram delivery.

**When to use `critical`:** For blocking events that need the user's attention at the next pulse regardless of recent conversation — e.g., approval requests, failed jobs, task completions the user is waiting on.

**When to use `bypass`:** For true system emergencies where DND must be overridden and a few seconds matters — e.g., health-monitor alerts, security events, service-down notifications. Requires explicit operator opt-in (`plugins.max_priority_cap: bypass`). Use sparingly. The immediate `fire_now()` trigger is only fired for `bypass`, not `critical`.

**Example — critical-priority plugin:**
```yaml
---
name: my_alert_plugin
active: true
max_priority: critical        # operator must allow this via max_priority_cap >= critical (default)
permissions:
  output_mind_queue: true
hooks: []
---
```

**Core resources** (always available to any plugin via `name: <resource>`):
`gmail`, `gcal`, `filesystem`, `shell`, `web_fetch`, `soul_state`, `mind_queue`, `agent_tasks`, `telegram_outbound`.


**Invocation**

```text
python3 scripts/tool.py \
  --mode callable \
  --args-json <tmp> \
  --state-json <tmp>
```

**Stdout (JSON object):**

```json
{
  "label": "Short title",
  "content": "Raw facts for the next LLM — not user-facing copy",
  "urgency": "normal",
  "error": null
}
```

**`urgency`:** `high` / `urgent` may enqueue **`mind_queue`** for **`voice_dispatcher`** (phrased with SOUL). Discovery: **`get_callable_manifest()`**; execution: **`run_callable_skill()`**; Telegram: OpenRouter native tools + **`callable_manifest_to_openrouter_tools`**.

---

### `post_reflection`

Runs after reflection output is produced (fire-and-forget).

```yaml
- type: post_reflection
  target: deep                  # deep | inner_monologue | morning | afternoon | all
  entry: scripts/post_reflect.py
  timeout: 60
  description: "…"
```

**Invocation**

```text
python3 scripts/post_reflect.py \
  --mode post_reflection \
  --reflection-type <target> \
  --state-json <tmp> \
  --reflection-output-json <tmp>
```

**Output:** none required.

---

### `soul_engine` (tick events) {#soul_engine-tick-events}

Runs during **`soul_engine.py`** tick at specific internal decision points. Plugins receive
a structured payload describing the data soul_engine is about to act on, and can return
a veto or override for that decision. **Fails open** — errors or timeouts never block the tick.

```yaml
- type: soul_engine
  target: profile_gap     # the only defined target currently
  entry: scripts/hook.py
  timeout: 8
  priority: 50
  description: "Suppress profile-gap questions already answered in external source"
```

**Invocation**

```text
python3 scripts/hook.py \
  --mode soul_engine \
  --target <target> \
  --payload-json <tmp> \
  --state-json <tmp>
```

#### Target: `profile_gap`

Called **once per soul_engine tick** (not per field) when `user_profile_gaps.json` has
candidate fields to enqueue as `ask_user` self_questions. Plugins can suppress specific
fields — e.g. because an external source of truth (CRM, notes database, prior session log)
already has that fact. Suppression is **union** across all plugins (any plugin can veto a
field; none can un-veto a veto by another plugin).

**`payload-json`** fields:

| Field | Type | Description |
|-------|------|-------------|
| `version` | int | `1` for current schema |
| `candidate_gaps` | list | Fields soul_engine is considering: `[{"field": str, "question": str, "confidence": float}]`. These are fields that passed core deduplication (no pending question, no Chroma hit). |

**Stdout (JSON object):**

| Field | Type | Description |
|-------|------|-------------|
| `suppress_fields` | list[str] | Field names to skip. Use the exact `field` value from `candidate_gaps`. |
| `reason` | str | Optional human-readable reason (logged). |

**Example output:**
```json
{
  "suppress_fields": ["name", "location"],
  "reason": "Found in operator notes — the user / Clovis CA"
}
```

Omit `suppress_fields` or return `[]` to suppress nothing. Plugin crashes or invalid JSON → no fields suppressed.

---

## State snapshot — `furoshiki-state.json`

Built by **`plugin_loader._build_state_snapshot()`** for hooks that receive **`--state-json`** (not for standalone scheduled entry scripts — those may read **`heartbeat-state.json`** via **`db`**). Typical fields:

```json
{
  "generated_at": "2026-03-26T09:00:00Z",
  "needs": { "connection": 0.72, "self_knowledge": 0.31, "curiosity": 0.68, "purpose": 0.55, "rest": 0.44 },
  "mood": "reflective",
  "time_of_day": "morning",
  "is_dnd": false,
  "user_timezone": "America/Los_Angeles",
  "user_local_time": "09:00",
  "user_weekday": "Sunday",
  "last_conversation_at": "2026-03-25T22:14:00Z",
  "hours_since_last_conversation": 10.8,
  "emotional_weight_today": 0.6,
  "health_score": 0.85,
  "active_plugin_count": 3
}
```

---

## Plugin chain — `prior-context-json`

Only **`pre_reflection`**: JSON array of prior plugins’ outputs in **priority** order; your script may read it and append to the chain with your stdout JSON.

---

## Permissions model

Declared under **`permissions:`**. The static pre-screen (in `self_modification_dispatcher`) and the LLM review pass both enforce alignment; runtime enforces declared scope on **writes** (`mind-queue`, `llm`) and on **scoped semantic reads** (`GET /user-facts`, `GET /memories-search`).

| Permission | Allows |
|------------|--------|
| `read_user_facts` | Semantic search over `user_facts` via Plugin API (`GET /api/v1/plugin/user-facts`) |
| `read_memories` | Semantic search over `memories` via Plugin API (`GET /api/v1/plugin/memories-search`) |
| `output_mind_queue` | Insert into `mind_queue` via Plugin API (`POST /api/v1/plugin/mind-queue`) or mediated stdout |
| `llm` | LLM call via Plugin API (`POST /api/v1/plugin/llm`) — budget enforced |
| `network` | Outbound network calls from the plugin subprocess |
| `openrouter_web` | Sets **`FUROSHIKI_PLUGIN_OPENROUTER_WEB=1`** so the LLM endpoint may use OpenRouter **:online** where applicable |

**Retired permissions** (v2 — no longer valid): `read_db`, `write_db`, `write_mind_queue`. These implied direct SQLite access, which is no longer possible. Replace with `output_mind_queue` and the Plugin API.

**Plugin-owned SQLite:** Plugins may use `sqlite3` to create and manage their **own** database under `$FUROSHIKI_PLUGIN_DATA_DIR` (their `data/` folder). They must **never** use `sqlite3.connect()` to open `longmemory.sqlite`, `logs.sqlite`, or any path constructed from `FUROSHIKI_DATA`. Violations are caught by the static pre-screen in `self_modification_dispatcher`.

---

## Execution environment

Subprocess environment is built by **`build_plugin_subprocess_env`** in **`plugin_loader.py`** (scheduled runs use the same helper). **`scripts/` is not on PYTHONPATH** — plugins can only import the Plugin SDK and their own scripts.

| Variable | Role |
|----------|------|
| `FUROSHIKI_ROOT` | **`FUROSHIKI_SRC`** (code tree) |
| `FUROSHIKI_DATA` | Instance root (**set to the host process `db.ROOT`**) so `$FUROSHIKI_DATA/plugins/<name>/settings.json` matches the dashboard |
| `FUROSHIKI_PLUGIN_NAME` | Directory name of this plugin (basename of `FUROSHIKI_PLUGIN_DIR`) |
| `FUROSHIKI_PLUGIN_DIR` | This plugin’s directory |
| `FUROSHIKI_PLUGIN_DATA_DIR` | `…/data` (created if missing) |
| `FUROSHIKI_PLUGIN_OPENROUTER_WEB` | `0` or `1` |
| `FUROSHIKI_PLUGIN_API_URL` | Base URL for the Plugin HTTP API (e.g. `http://127.0.0.1:7432/api/v1/plugin`) |
| `FUROSHIKI_PLUGIN_API_TOKEN` | Short-lived Bearer token for this invocation (expires shortly after the hook’s timeout) |
| `PYTHONPATH` | **`<SRC>/scripts`** (SDK only) + `<plugin>/scripts` — **`scripts/` internals are NOT importable** |

**Must not:** import core Furoshiki modules (`db`, `llm`, `soul_engine`, dispatchers, etc.), open `longmemory.sqlite` or `logs.sqlite` directly, write protected paths, or bypass **`voice_dispatcher`** for proactive chat.

---

## Python API — `scripts/plugin_loader.py`

Public entrypoints (safe to call; return empty / no-op if no plugins):

```python
from plugin_loader import (
    parse_plugin_manifest,
    build_plugin_subprocess_env,
    run_pre_conversation,
    run_post_conversation,
    run_pre_reflection,
    run_post_reflection,
    get_callable_manifest,
    callable_manifest_to_openrouter_tools,
    run_callable_skill,
    format_plugin_contexts_for_prompt,
)
```

There is **no** `get_plugins()` export — discovery is internal. **`cron_run_log`** may receive rows for observability.

---

## Schedule merge — `scripts/plugin_schedules.py`

- **`merge_plugin_tasks_into_schedules(plugins_dir, schedules_path)`** — used by **`furoshiki plugins reload`**.
- Strips tasks with **`id`** prefix **`plugin__`**, appends fresh rows for each active `cron` hook.
- Deletes legacy **`$FUROSHIKI_DATA/plugins.cron`** if present.

---

## Operator CLI

```bash
furoshiki plugins list
furoshiki plugins show <name>
furoshiki plugins apply <proposal-file>    # from $FUROSHIKI_DATA/proposals/
furoshiki plugins enable <name> | disable <name>
furoshiki plugins delete <name> --yes
furoshiki plugins reload                    # sync plugin cron hooks → memory/schedules.json
```

**Dashboard (repair API → Plugins page):** zip a plugin directory (`PLUGIN.md` at archive root or inside one top-level folder), upload via **Install from zip**, configure **`settings_schema`** if present, then **enable**. The page also serves the full **`PLUGIN-SPEC.md`** text with a **Copy full spec** button for coding agents. Installs are left **`active: false`** until you enable them. API: **`POST /api/v1/plugins/install`** (multipart, admin scope), **`GET /api/v1/plugins/spec`** (read scope).

**Extensions hub (delegations + repairs + proposals — not PLUGIN.md specific):**

```bash
furoshiki extensions list
furoshiki extensions plugins list
furoshiki extensions plugins reload
# … see furoshiki extensions --help
```

---

## Self-modification flow (skill / plugin proposals)

1. Drafts under **`$FUROSHIKI_DATA/drafts/<name>/`**.  
2. Review pass → proposal **`$FUROSHIKI_DATA/proposals/…md`**.  
3. **`furoshiki plugins apply <file>`** installs into **`plugins/`** when the proposal embeds an approved plugin.  
4. **`furoshiki plugins reload`** registers **`cron`** hooks with Brain.

---

## Example manifest

```yaml
---
name: game-activity-analyzer
version: 1.0.0
description: >
  Weekly compile + deep reflection context + post-reflection logging.
active: true
hooks:
  - type: cron
    schedule: "0 8 * * 0"
    entry: scripts/compile_weekly.py
    timeout: 120
    description: "Compile weekly stats (Sunday 08:00 UTC)"
  - type: pre_reflection
    target: deep
    entry: scripts/inject_context.py
    timeout: 30
    priority: 40
    description: "Inject summary into deep reflection"
  - type: post_reflection
    target: deep
    entry: scripts/log_conclusions.py
    timeout: 30
    description: "Log whether themes appeared in output"
permissions:
  read_user_facts: true
  output_mind_queue: false
  llm: false
  network: false
  openrouter_web: false
---

# Game activity analyzer (example)

See prose in repo examples for full narrative.
```

---

## Migration: legacy “web search” plugins

Reflection now uses OpenRouter **:online** where configured in **`llm.py`** / routing. Remove redundant search-only plugins unless they add non-reflection value. Use **`openrouter_web: true`** only when **this** plugin’s code calls **`llm.py`** and should opt into online search.

---

## V1 vs future

| Hook / area | Status |
|-------------|--------|
| `cron` → Brain `schedules.json` | **Supported** |
| `pre_conversation`, `post_conversation` | **Supported** |
| `pre_reflection` / `post_reflection` (`deep`, `inner_monologue`, `morning`, `afternoon`, `all`) | **Supported** |
| `callable` | **Supported** |
| `soul_engine` tick events — `profile_gap` target | **Supported** |
| `soul_engine` additional targets (`fill_mind_queue`, `process_events`, …) | Planned |
| `voice_dispatch` contribution hooks | Planned |

---

## Implementation reference

| Module | Role |
|--------|------|
| `scripts/plugin_loader.py` | Discovery, subprocess hooks, **`get_callable_manifest`**, **`run_callable_skill`**, **`run_soul_engine_hook`**, **`build_plugin_subprocess_env`**, **`format_plugin_contexts_for_prompt`** |
| `scripts/plugin_schedules.py` | Merge **`cron`** hooks into **`schedules.json`** |
| `scripts/run_plugin_scheduled.py` | Brain entrypoint for scheduled plugin scripts |
| `scripts/skill_orchestrator.py` | Callable planning + urgent **`mind_queue`** |
| `scripts/self_modification_dispatcher.py` | Draft + review + proposal for **`task_type=self_modification`** |
| `bin/furoshiki` | **`plugins`** and **`extensions`** subcommands |

---

*When architecture changes, update this file and run **`furoshiki design update`** so `design_docs` and the generated reality snapshot stay aligned.*
