diff --git a/docs/design/built-in-first-party-packs.md b/docs/design/built-in-first-party-packs.md
index 1ff093c0a..61b2fbd39 100644
--- a/docs/design/built-in-first-party-packs.md
+++ b/docs/design/built-in-first-party-packs.md
@@ -80,9 +80,9 @@ shipped first-party packs directory, and resolve those packs *in place* as a
dedicated band in `buildPackList()`.** They are NOT copied into any scope's
`.bobbit/config/market-packs/`.
-"Auto-installed" therefore means **present + active by default**: a built-in pack
-is always resolvable; the only opt-out is **disable** via the #734 activation
-overrides (default = enabled). Updates ride the app upgrade for free (the shipped
+"Auto-installed" therefore means **present in the built-in band**: most built-in packs are
+active by default, while a pack that declares `defaultDisabled: true` resolves with all of its
+contributions disabled until explicitly enabled or already configured. Updates ride the app upgrade for free (the shipped
dir is replaced on install/upgrade).
### Rejected alternative — copy-install + opt-out ledger
diff --git a/docs/design/hindsight-pack-external.md b/docs/design/hindsight-pack-external.md
index b45d9843f..a17180772 100644
--- a/docs/design/hindsight-pack-external.md
+++ b/docs/design/hindsight-pack-external.md
@@ -3,8 +3,10 @@
Status: design / implementation blueprint. Scope is the **external-URL** Hindsight memory pack:
a REST client, an in-process stub harness, the lifecycle provider, pack routes + config surface,
bank/tag derivation, dormancy, and built-in-band registration (dormant). Managed Docker runtime,
-Postgres, volumes, deployment-mode selection, the explicit agent tools, and the native panel are
-**out of scope** (G2.3 / G3 / G4).
+Postgres, volumes, deployment-mode selection, the explicit agent tools, and the native panel were
+**out of scope** of this blueprint (G2.3 / G3 / G4) and shipped later: the managed runtime in P3,
+the native panel in P4, and the agent tools `hindsight_recall/retain/reflect` in **P5** (see
+[hindsight-memory.md → Agent tools](../hindsight-memory.md#agent-tools)).
> Authority notes. This folds the impl-plan's **G2.1 + G2.2** ([extension-platform-implementation-plan.md
> §G2](extension-platform-implementation-plan.md)) and applies two owner overrides recorded for
@@ -73,7 +75,7 @@ Pack layout (this goal):
```
market-packs/hindsight/
pack.yaml # schema 2; contents.providers: [memory]; routes
- providers/memory.yaml # kind: memory; hooks: all five; config surface
+ providers/memory.yaml # kind: memory; lifecycle hooks; config surface
src/hindsight-client.ts # → lib/hindsight-client.mjs
src/provider.ts # → lib/provider.mjs
src/routes.ts # → lib/routes.mjs
@@ -89,7 +91,7 @@ schema: 2
name: hindsight
description: >-
Persistent agent memory backed by Hindsight (recall/retain/reflect over a shared,
- tag-scoped bank). Dormant until a Hindsight URL is configured. See docs/design/agent-memory.md.
+ tag-scoped bank). Default-disabled until Marketplace setup enables/configures it. See docs/design/agent-memory.md.
version: 1.0.0
contents:
roles: []
@@ -114,9 +116,9 @@ routes:
id: memory
kind: memory
module: ../lib/provider.mjs
-hooks: [sessionSetup, beforePrompt, afterTurn, beforeCompact, sessionShutdown]
-budget: { maxTokens: 1200, timeoutMs: 1500 }
-defaultEnabled: true # activation is additionally gated on externalUrl (see below)
+hooks: [sessionSetup, beforePrompt, afterTurn, beforeCompact, sessionShutdown, goalCompleted]
+budget: { maxTokens: 1200, timeoutMs: 4500 }
+defaultEnabled: true # provider default once the pack itself is enabled/configured
config:
mode: { type: enum, values: [external, managed], default: external } # managed reserved for G3
externalUrl: { type: string, optional: true }
@@ -127,7 +129,7 @@ config:
autoRecall: { type: boolean, default: true }
autoRetain: { type: boolean, default: true }
recallBudget:{ type: number, default: 1200 }
- timeoutMs: { type: number, default: 1500 }
+ timeoutMs: { type: number, default: 4000 }
activation:
requiresConfig: [externalUrl] # host omits the provider entirely until configured
```
@@ -136,9 +138,11 @@ Loaded via the **pack-contributions path** (`pack-contributions.ts`-style loader
`PackContributionRegistry`, keyed `(packId, contributionId)`), per impl-plan §0.2 — NOT a new
`EntityType`. The provider runs on the Extension Host worker tier exactly like the `provider-demo`
fixture. Per-entity activation still respects `DisabledRefs.providers` / `pack_activation`, but
-Hindsight also declares `activation.requiresConfig: [externalUrl]`. The registry/loader omits this
-provider until the effective config has a non-empty `externalUrl`; this is what prevents bridge
-injection and per-turn hook calls on a fresh install.
+Hindsight also declares pack-level `defaultDisabled: true` and provider-level activation gates. On
+fresh installs the pack-level overlay disables all contributions; after setup enables/configures the
+pack, `activation.requiresConfig: [externalUrl]` (or managed-mode `activeWhenConfig` in the current
+pack) keeps the provider omitted until the effective config/runtime is usable. This is what prevents
+bridge injection and per-turn hook calls before opt-in.
---
@@ -172,7 +176,7 @@ export function createClient(cfg: {
baseUrl: string;
apiKey?: string;
namespace?: string; // default "default"
- timeoutMs?: number; // default 1500
+ timeoutMs?: number; // default 4000
}): HindsightClient;
```
@@ -186,7 +190,7 @@ export class HindsightError extends Error {
```
- Every method wraps its `fetch` in an `AbortController` armed with `cfg.timeoutMs` (default
- **1500 ms**). Abort ⇒ `HindsightError{ kind:"timeout" }`, thrown **within budget** (the test
+ **4000 ms**). Abort ⇒ `HindsightError{ kind:"timeout" }`, thrown **within budget** (the test
asserts elapsed ≤ a small margin over `timeoutMs`).
- Non-2xx ⇒ `HindsightError{ kind:"http", status }`.
- DNS/connection refused/socket error ⇒ `HindsightError{ kind:"network" }`.
@@ -289,13 +293,14 @@ queue, last error, optional bank-ensured marker) lives in `ctx.host.store`, neve
### 5.1 Dormancy gate (the central invariant)
-Before any hook does work it evaluates **`isActive(ctx)`**: `config.mode === "external"` AND
-`config.externalUrl` is a non-empty string. If not active, **every hook returns immediately**:
-`sessionSetup`/`beforePrompt` ⇒ `{ blocks: [] }`; `afterTurn`/`beforeCompact`/`sessionShutdown`
-⇒ no-op. This is a defensive backstop. The primary dormant guarantee is earlier in the host:
-`activation.requiresConfig: [externalUrl]` means an unconfigured pack contributes no active
-provider, so the provider bridge is not injected and no per-turn hook route is called. No client is
-constructed, no Hindsight network is touched, and spawn args/prompt text stay at the no-pack
+Before any hook does work it evaluates **`isActive(ctx)`**: external mode requires a non-empty
+`externalUrl`, while managed modes require a host-injected running runtime. If not active, **every
+hook returns immediately**: `sessionSetup`/`beforePrompt` ⇒ `{ blocks: [] }`;
+`afterTurn`/`beforeCompact`/`sessionShutdown`/`goalCompleted` ⇒ no-op. This is a defensive
+backstop. The primary dormant guarantee is earlier in the host: `defaultDisabled: true` disables all
+pack contributions on a fresh unconfigured install, and provider activation gates keep the provider
+omitted until setup/config makes it usable. No client is constructed, no Hindsight network is
+touched, and spawn args/prompt text stay at the no-pack
baseline until configured (§9.5).
Health is treated as a runtime condition layered on top of activation: when active but the client
@@ -415,13 +420,14 @@ The provider must receive a **flat** config object that is **store-over-yaml-def
1. The loader first resolves each `providers/memory.yaml` config schema entry to its `.default`
value (or `undefined` for optional fields), producing values such as `mode: "external"`,
- `bank: "bobbit"`, `namespace: "default"`, `autoRecall: true`, and `timeoutMs: 1500` — not raw
+ `bank: "bobbit"`, `namespace: "default"`, `autoRecall: true`, and `timeoutMs: 4000` — not raw
`{ type, default }` schema objects.
2. The `config` route persists validated overrides to the pack store.
3. The loader overlays persisted store config over the flat defaults before constructing
`ctx.config`.
-4. The provider activation filter evaluates `activation.requiresConfig` against this same
- effective flat config; absent `externalUrl` means the provider is omitted before bridge injection.
+4. The default-disabled pack overlay disables all contributions on fresh unconfigured installs;
+ after setup enables/configures the pack, provider activation filters evaluate the effective flat
+ config/runtime before bridge injection.
If G1.1 currently exposes only static yaml, both the default-resolution step and the store-config
overlay are added **in the loader path, not the provider** (so every provider benefits and
@@ -495,6 +501,11 @@ A separate defensive unit test calls the provider hooks directly with no `extern
## 11. Non-goals (tracked elsewhere)
-- Explicit agent tools `hindsight_recall/retain/reflect` + native panel + entrypoints — **G2.3**.
-- Managed Docker runtime + Postgres + `~/.hindsight` + deployment-mode selection — **G3**.
+- Explicit agent tools `hindsight_recall/retain/reflect` — **G2.3** (shipped in **P5**; see
+ [hindsight-memory.md → Agent tools](../hindsight-memory.md#agent-tools)). The **native panel +
+ entrypoints** half of G2.3 shipped in **P4** — see
+ [hindsight-memory.md → Native config & status panel](../hindsight-memory.md#native-config--status-panel)
+ and [hindsight-panel-p4-implementation.md](hindsight-panel-p4-implementation.md).
+- Managed Docker runtime + Postgres + `~/.hindsight` + deployment-mode selection — **G3** (shipped
+ in **P3**; see [managed-runtimes.md](../managed-runtimes.md#p3--deployment-modes-consent--lifecycle)).
- Mental-models / reflect UI / cross-engine dedupe / cost surfacing — **G4**.
diff --git a/docs/design/hindsight-panel-p4-implementation.md b/docs/design/hindsight-panel-p4-implementation.md
new file mode 100644
index 000000000..e3e81b9f6
--- /dev/null
+++ b/docs/design/hindsight-panel-p4-implementation.md
@@ -0,0 +1,480 @@
+# P4 — Hindsight native config/status panel & entrypoints (implementation design)
+
+Status: design. Owner gate: `design-doc`. Builds on P1 (runtime descriptor), P2
+(pack routes: `config`/`status`/`recall`/`retain`/`reflect`/`banks`), and P3
+(deployment modes + managed-runtime linkage). This document is the implementation
+contract for the **only** remaining G2.3 deliverable in the Hindsight pack: a
+first-party **native panel** that replaces store-seeding as the configuration path,
+plus its **command-palette** and **deep-link** entrypoints.
+
+It introduces **no new server routes** — the panel is a pure client of the existing
+P2 routes through the versioned Host API. No production code/tests are written by
+this doc; it scopes them.
+
+---
+
+## 1. Goal & non-goals
+
+**Goal.** Give a user a native, theme-compatible panel to:
+
+- pick the **deployment mode** (`external` / `managed` / `managed-external-postgres`);
+- configure **external URL**, **API key**, **bank**, **namespace**, **managed
+ data-dir**, **external Postgres URL**, and the recall/retain toggles & budgets,
+ where relevant to the chosen mode;
+- see a **runtime status card** (configured / healthy / mode / bank / namespace +
+ a logs link) driven by the P2 `status` route;
+- **search memory** via the existing `recall` route and render results;
+- see **recent retains / retry-queue counter** via the `status` route;
+- **persist** config through the existing `config` route (replacing the
+ E2E-only store-seeding configuration path).
+
+Two entrypoints expose it: a **command-palette** launcher (owner-session panel,
+`action`-less `PanelTarget`) and a **deep link** (`kind:"route"`, `routeId:"hindsight"`).
+
+**Non-goals (out of scope for P4).**
+
+- New server routes or changes to `src/routes.ts` / `src/shared.ts` route logic
+ (the panel consumes the **frozen** P2 surface as-is).
+- The explicit `hindsight_*` agent tools / reflect UI (separate work; the panel
+ does not call `reflect`).
+- Starting/stopping the managed Docker runtime from the panel. The panel writes
+ **config** only; runtime enable/start stays in the marketplace/runtime UI
+ (`runtimes/hindsight.yaml` `startPolicy: on-enable`). The status card surfaces
+ health read-only.
+- Opening a GitHub PR.
+
+---
+
+## 2. Files to add / change
+
+### 2.1 Add — pack panel source (client, bundled)
+
+| Path | What |
+|---|---|
+| `market-packs/hindsight/src/panel.js` | Panel source. Default-export factory `createPanel({ html, nothing, renderHeader })` returning `{ render(params, host) }`. Built to `lib/HindsightPanel.js`. **No `lit` import** (host-injected). |
+
+> Naming: source stays `src/panel.js` (mirrors `pr-walkthrough/src/panel.js`); the
+> **built** artifact is `lib/HindsightPanel.js` per the goal. Authoring in `.js`
+> (not `.ts`) matches pr-walkthrough's client panel and avoids a typecheck entry
+> for browser-only code. If TS is preferred, `src/HindsightPanel.ts` is acceptable
+> so long as `out` stays `lib/HindsightPanel.js`.
+
+### 2.2 Add — panel descriptor
+
+| Path | What |
+|---|---|
+| `market-packs/hindsight/panels/hindsight-memory.yaml` | Auto-discovered panel manifest. `id: hindsight.panel`, `title: Hindsight Memory`, `entry: ../lib/HindsightPanel.js`. **Singleton** instance mode (no `instanceParam`) — one config panel per session view. |
+
+### 2.3 Add — entrypoints
+
+| Path | What |
+|---|---|
+| `market-packs/hindsight/entrypoints/hindsight-palette.yaml` | `kind: command-palette`, `label: Hindsight Memory`, `target: { panelId: hindsight.panel }` — a **PanelTarget** launcher (no `action: spawn`), opens the panel in the active/owner session via `openPackPanel`. |
+| `market-packs/hindsight/entrypoints/hindsight-route.yaml` | `kind: route`, `routeId: hindsight`, `target: { panelId: hindsight.panel }`, `paramKeys: []`. Deep link `#/ext/hindsight` reopens the panel rehydrated from the routes. |
+
+### 2.4 Change — `market-packs/hindsight/pack.yaml`
+
+```yaml
+contents:
+ roles: []
+ tools: []
+ skills: []
+ entrypoints: [hindsight-palette, hindsight-route] # was []
+ providers: [memory]
+ hooks: []
+ mcp: []
+ pi-extensions: []
+ runtimes: [hindsight]
+ workflows: []
+```
+
+Update the trailing `# panel + deep link land in G2.3` comment to reflect that
+they have landed. The `routes:` block is unchanged (the panel reuses
+`config`/`status`/`recall`).
+
+### 2.5 Change — `scripts/build-market-packs.mjs`
+
+Add a **client** panel entry to the `hindsight` pack's `entries` array (alongside
+the three existing `platform:"node"` server bundles). It is browser-platform
+(default), so `lit` stays external and the bundle is self-contained:
+
+```js
+{ in: "panel.js", out: "lib/HindsightPanel.js" } // CLIENT panel (browser)
+```
+
+Drop/adjust the existing `// (panel + tools land in G2.3)` comment.
+
+### 2.6 Add — E2E fixtures & spec (owned by the tester; scoped here)
+
+| Path | What |
+|---|---|
+| `tests/e2e/ui/hindsight-pack.spec.ts` | Browser E2E (see §7). |
+| (reuse) `tests/e2e/hindsight-stub.mjs` | Existing in-process Hindsight stub; reused to back `status.healthy` + `recall` results. |
+
+No change to `tests/e2e/hindsight-external.spec.ts` (API spec); its `seedConfig`
+seam stays for the API tests. The **panel** becomes the user-facing config path;
+store-seeding remains a test-only mechanism.
+
+---
+
+## 3. Entrypoint YAML shape (exact)
+
+`panels/hindsight-memory.yaml`:
+
+```yaml
+id: hindsight.panel
+title: Hindsight Memory
+# Singleton config/status panel — one per session view (no instanceParam). `entry`
+# resolves relative to THIS yaml (panels/) and stays inside the pack root; the
+# built bundle lives in the shared lib/ dir and is lazy-imported by the client
+# pack-panels registry, opened via host.ui.openPanel({ panelId }). It reads/writes
+# ONLY through the Host API (host.callRoute config|status|recall, host.store) —
+# never a raw fetch.
+entry: ../lib/HindsightPanel.js
+```
+
+`entrypoints/hindsight-palette.yaml`:
+
+```yaml
+id: hindsight.palette
+kind: command-palette
+label: Hindsight Memory
+# A command-palette LAUNCHER whose target is a PanelTarget (NO action:"spawn"):
+# its click opens hindsight.panel in the ACTIVE (owner) session via openPackPanel.
+# This is the config/status surface — there is no sub-agent to mint, unlike the
+# pr-walkthrough spawn launchers.
+target:
+ panelId: hindsight.panel
+```
+
+`entrypoints/hindsight-route.yaml`:
+
+```yaml
+id: hindsight.route
+kind: route
+routeId: hindsight
+target:
+ panelId: hindsight.panel
+# Deep-link: #/ext/hindsight resolves through the client pack-route registry and
+# reopens the singleton panel. No params are carried — the panel rehydrates its
+# state entirely from the config/status routes on mount, so paramKeys is empty.
+paramKeys: []
+```
+
+> **Why a `PanelTarget` launcher, not `action:"spawn"`.** The pr-walkthrough
+> launchers spawn a read-only reviewer child and open the panel in *that* child
+> session. The Hindsight panel is a **config/status** surface bound to the
+> owner/active session — `runLauncherEntrypoint` routes a bare `PanelTarget`
+> straight to `openPackPanel(target, packId)` (see
+> `src/app/pack-entrypoints.ts`), exactly like the artifacts deep-link panel.
+
+---
+
+## 4. Panel module contract & Host API usage
+
+### 4.1 Factory shape (mirrors artifacts / pr-walkthrough)
+
+```js
+export default function createPanel({ html, nothing, renderHeader }) {
+ // module-closure caches survive repaints (config snapshot, status snapshot,
+ // search results, in-flight flags), keyed by the bound session id.
+ return {
+ render(params, host) { /* PURE projection; kicks async fetches ONCE */ }
+ };
+}
+```
+
+- The client lazy-imports the Blob-URL module and calls the default export with
+ the app's own lit instance (`{ html, nothing, renderHeader }`) — the pack never
+ bare-imports `lit` (`pack-panels.ts::loadPanelModule`).
+- `render(params, host)` is a **pure projection**: it must not auto-invoke
+ capabilities on every repaint. The documented pattern (artifacts) is to kick a
+ fetch **once** (guarded by a closure cache), call `host.requestRender()` when it
+ resolves, and re-render from cache thereafter.
+- `params.__sessionId` is injected by the host (`pack-panels.ts`); use it as the
+ closure cache key so each session view keeps its own snapshot.
+- Feature-detect Phase-2 capabilities via `host.capabilities.callRoute` /
+ `host.capabilities.store` before use (they are present-but-throwing stubs on a
+ Phase-1 host). Degrade to a "memory unavailable on this host" message.
+
+### 4.2 Route calls (all via `host.callRoute`, never raw fetch)
+
+The pack declares routes `[status, recall, retain, reflect, banks, config]`
+(`pack.yaml`). P4 uses **three**:
+
+| Call | When | Request | Response (P2 contract) |
+|---|---|---|---|
+| `host.callRoute("config", { method:"GET" })` | on mount (once per session) | — | `{ ok, configured, config }` where `config` is **redacted** (`apiKeySet`/`externalDatabaseUrlSet`/`llmApiKeySet` booleans, never raw secrets). |
+| `host.callRoute("config", { method:"POST", body })` | Save clicked | partial overrides (only changed keys) | `{ ok:true, configured, config }` (new redacted effective config) or `{ ok:false, error:"CONFIG_INVALID", errors:[…] }`. |
+| `host.callRoute("status", { method:"GET" })` | on mount + after Save + manual Refresh + a bounded poll while a managed mode is "configured but not yet healthy" | — | `{ configured, mode, bank, namespace, recallScope, autoRecall, autoRetain, queueDepth, healthy, lastError? }`. |
+| `host.callRoute("recall", { method:"POST", body:{ query, scope? } })` | Search submitted | `{ query, scope?: "project"\|"all" }` | `{ configured, memories:[{ text, score?, id? }], error? }`. Dormant ⇒ `{ configured:false, memories:[] }`. |
+
+`scope` defaults to the configured `recallScope`; the search UI offers an explicit
+project/all toggle that maps to the route `scope` param (the route resolves the
+real `projectId` from its server ctx for `project` scope).
+
+### 4.3 `host.store` usage (optional, UI-only)
+
+The **authoritative** config store is the pack route's `provider-config:memory`
+key (written server-side by the `config` route). The panel **must not** write that
+key directly — it goes through the route so validation + redaction apply. The
+panel **may** use `host.store` for **UI-only** ephemera (e.g. last search query,
+panel section collapse state) under distinct keys; this is best-effort and never
+holds secrets.
+
+### 4.4 No `host.session` use
+
+The config panel reads/writes config + status only. It does not read the
+transcript or post messages, so `host.session` is unused (keeps the surface
+minimal and the security story simple).
+
+---
+
+## 5. Config data model (panel ↔ `config` route)
+
+Single source of truth: `market-packs/hindsight/src/shared.ts`
+(`EffectiveConfig`, `CONFIG_DEFAULTS`, `validateConfigOverrides`, `redactConfig`)
+mirrored by `providers/memory.yaml`. The panel mirrors this shape; it does **not**
+re-derive defaults — it renders the `config` GET response and only POSTs **changed**
+keys.
+
+### 5.1 Fields, by mode
+
+| Field | Type | Modes | Notes |
+|---|---|---|---|
+| `mode` | enum `external` \| `managed` \| `managed-external-postgres` | all | Drives which fields below are shown/required. |
+| `externalUrl` | string (optional) | **external** | The single switch that activates external mode (dormant when empty). |
+| `apiKey` | secret (optional) | external + managed | Client `Authorization`. GET surface exposes only `apiKeySet:boolean`. |
+| `externalDatabaseUrl` | secret (optional) | **managed-external-postgres** | → runtime `HINDSIGHT_API_DATABASE_URL`. GET: `externalDatabaseUrlSet`. |
+| `llmApiKey` | secret (optional) | **managed** + managed-external-postgres | → runtime `HINDSIGHT_API_LLM_API_KEY`. GET: `llmApiKeySet`. |
+| `dataDir` | string (default `~/.hindsight`) | **managed** | Host bind-mount path for managed Postgres data. |
+| `bank` | string (default `bobbit`) | all | Shared tag-scoped bank id. |
+| `namespace` | string (default `default`) | all | Hindsight namespace path segment. |
+| `recallScope` | enum `project` \| `all` (default `all`) | all | |
+| `autoRecall` / `autoRetain` | boolean (default `true`) | all | |
+| `recallBudget` | number (default `1200`) | all | positive. |
+| `timeoutMs` | number (default `4000`) | all | positive. |
+
+### 5.2 Secret handling (panel rules)
+
+- The GET response never returns raw secrets — only `*Set` booleans. The panel
+ renders a secret input with placeholder "•••• set" when `*Set` is true, empty
+ otherwise.
+- An **untouched** secret field is **omitted** from the POST body (so the stored
+ value is preserved — the route merges `{ ...prev, ...overrides }`).
+- A field the user **clears** sends `""` (the route's `validateConfigOverrides`
+ treats `""`/`null` as "clear this optional secret").
+- A field the user **edits** sends the new string.
+
+### 5.3 Validation parity
+
+The panel does light client-side gating (mode-required fields), but the route's
+`validateConfigOverrides` is authoritative: on `{ ok:false, errors }` the panel
+renders the `errors[]` inline next to Save and does not mutate its snapshot. This
+keeps a single validation source of truth.
+
+### 5.4 Mode-conditional required fields (client gate, mirrors `isConfigured`)
+
+- `external`: Save is meaningful only with a non-empty `externalUrl` (else the
+ provider stays dormant — surface a hint, do not block).
+- `managed`: requires `llmApiKey` to actually start (per `runtimes/hindsight.yaml`
+ + `memory.yaml`); the panel surfaces this as a "required to start" hint, but
+ `isConfigured` is true on mode select alone, so Save still persists.
+- `managed-external-postgres`: requires `externalDatabaseUrl` + `llmApiKey` hints.
+
+---
+
+## 6. Status & search rendering
+
+### 6.1 Layout (theme-compatible)
+
+A single scrollable panel with three sections, each a `card`-class block:
+
+1. **Status card** (top). Driven by `status`:
+ - **State badge**: derive from `{ configured, healthy, mode }`:
+ - not `configured` → `Dormant` (muted) — "Not configured".
+ - `configured` + `healthy` → `Connected` (`--positive`).
+ - `configured` + `!healthy` → external: `Unreachable` (`--negative`);
+ managed: `Starting / not running` (`--warning`) (managed health is
+ runtime-gated and may lag config).
+ - Rows: mode, bank, namespace, recallScope, autoRecall/autoRetain.
+ - **Retry-queue counter**: `queueDepth` rendered as a chip ("N queued retains").
+ - **Recent retains**: P2 `status` exposes `queueDepth` + `lastError` only (not a
+ retains list). P4 surfaces `queueDepth` as the retry-queue counter and, when
+ present, `lastError.{message,ts}` as a muted diagnostic line. A richer "recent
+ retains" list is **not** added in P4 (no route exists); the section header
+ reads "Retry queue" to match the available data. *(If a future route adds a
+ retains feed, this section extends without layout change.)*
+ - **Logs link**: a `host.ui.navigate`/anchor to the managed runtime logs surface
+ for managed modes (the marketplace/runtime logs view); for external mode the
+ "logs" affordance is hidden (no Bobbit-managed process). The link target is the
+ runtime/marketplace route, not a raw URL the panel constructs.
+ - **Refresh** button → re-calls `status`.
+
+2. **Config card** (middle). The form from §5, mode-conditional. A **Save** button
+ POSTs changed keys to `config`, then re-fetches `status`. Inline validation
+ errors render here.
+
+3. **Search card** (bottom). A query input + scope toggle (project/all) + Search
+ button → `recall`. Results render as a list of memory cards:
+ - each shows `memory.text` (escaped via the lit toolkit), optional `score` chip,
+ optional `id` (muted, monospace).
+ - empty result → "No memories matched." ; `configured:false` → "Configure
+ Hindsight to search memory." ; route `error` → muted error line.
+
+### 6.2 State machine (per session, closure-cached)
+
+`status ∈ loading | ready | error` for config + status snapshots; `search ∈
+idle | searching | results | empty | error`. Saves flip a transient `saving`
+flag. A bounded poll (e.g. 1.5s interval, capped) runs **only** while a managed
+mode is `configured && !healthy`, to flip the badge to Connected when the runtime
+comes up; it stops on healthy/terminal and on unmount. External mode does not poll
+(health is immediate).
+
+### 6.3 Test hooks
+
+Stable `data-testid`s for the E2E (names indicative):
+`hindsight-panel`, `hindsight-status-card`, `hindsight-status-badge`
+(+ `data-state`), `hindsight-queue-depth`, `hindsight-mode-select`,
+`hindsight-external-url`, `hindsight-api-key`, `hindsight-bank`,
+`hindsight-namespace`, `hindsight-data-dir`, `hindsight-external-db-url`,
+`hindsight-llm-api-key`, `hindsight-save`, `hindsight-config-error`,
+`hindsight-search-input`, `hindsight-search-scope`, `hindsight-search-submit`,
+`hindsight-memory-result` (+ `data-memory-id`), `hindsight-search-empty`,
+`hindsight-refresh`.
+
+---
+
+## 7. E2E fixture / test plan
+
+New browser spec `tests/e2e/ui/hindsight-pack.spec.ts`, modelled on
+`tests/e2e/ui/pr-walkthrough-pack.spec.ts` (built-in band, no install) and the
+config/stub seam of `tests/e2e/hindsight-external.spec.ts`.
+
+### 7.1 Harness & fixtures
+
+- The Hindsight pack resolves **active-by-default** via the built-in band (no
+ install) — assert it in `/api/ext/contributions` (panel `hindsight.panel` +
+ entrypoints `hindsight-palette`, `hindsight-route` + routes incl.
+ `config`/`status`/`recall`).
+- Back `status.healthy` + `recall` with the existing in-process
+ `tests/e2e/hindsight-stub.mjs` (`startHindsightStub`, `seedMemories`,
+ `setHealthy`). The gateway shares the in-process pack-store singleton, so the
+ panel's `config` POST and the stub URL line up the same way the API spec's
+ `seedConfig` does — except here config flows **through the panel**, proving the
+ panel replaces store-seeding as the config path.
+- Seed memories on the stub for the search assertion
+ (`seedMemories("bobbit", [{ text: "feature flag rollouts", id: "m1" }])`).
+
+### 7.2 Scenarios (the required coverage)
+
+1. **Open panel — command palette.** Run the palette launcher
+ (`__bobbitRunPackLauncher` hook → `hindsight.palette`, or the command-palette
+ UI); assert `hindsight.panel` mounts in the active session
+ (`[data-testid=hindsight-panel]`), status badge shows `Dormant` initially.
+2. **Set external URL + bank → Save.** Select mode `external`, type the stub URL
+ into `hindsight-external-url`, set `hindsight-bank` to `bobbit`, click
+ `hindsight-save`. Assert the `config` POST fired and returned `ok:true`, and the
+ panel reflects the persisted values.
+3. **Stub status flips to connected.** With the stub healthy, after Save (which
+ re-fetches `status`), assert `hindsight-status-badge` `data-state="connected"`
+ (`Connected`). Toggle `stub.setHealthy(false)` + Refresh → badge `unreachable`;
+ restore → `connected` again.
+4. **Search renders seeded memories.** Type a query into `hindsight-search-input`,
+ submit; assert a `hindsight-memory-result` with the seeded text renders
+ (escaped). Assert the stub recorded a `recall` against bank `bobbit`.
+5. **Persistence across reload.** Reload the page, re-open via the **deep link**
+ `#/ext/hindsight` (`navigateToHash`); assert the panel rehydrates `config` from
+ the route (external URL persisted, bank `bobbit`) and status badge is
+ `connected` — proving config persisted server-side via the route, not via a
+ client-only store.
+
+### 7.3 Secret-redaction assertion (security)
+
+After saving an `apiKey`, reload and assert the `hindsight-api-key` field shows the
+"set" placeholder and the raw key is **never** present in the DOM or the `config`
+GET payload (only `apiKeySet:true`).
+
+### 7.4 Skip-guard
+
+Gate the suite on the built panel + stub being present (mirror
+`hindsight-external.spec.ts`'s `DEPS_READY`), so the e2e phase stays green before
+this branch merges:
+
+```js
+const DEPS_READY = fs.existsSync(path.join(PACK_SRC, "lib", "HindsightPanel.js"))
+ && fs.existsSync(path.join(PACK_SRC, "panels", "hindsight-memory.yaml"))
+ && fs.existsSync(STUB_PATH);
+```
+
+---
+
+## 8. Security & theme constraints
+
+**Security.**
+
+- **No raw fetch / no escape hatch.** All dynamic data flows through the versioned
+ Host API: `host.callRoute` (pack-scoped to `/api/ext/hindsight/*`) and
+ `host.store`. The panel never constructs gateway URLs and never reaches another
+ pack's routes/store. This mirrors the pr-walkthrough/artifacts invariant.
+- **Secrets are write-only from the client.** The `config` GET surface returns only
+ `*Set` booleans (`redactConfig`); the panel never displays a stored secret value.
+ Untouched secret fields are omitted from POST so they are preserved; explicit
+ clear sends `""`. The route validates + persists; the panel trusts the route's
+ redaction.
+- **No auto-mutation on mount.** Mount triggers only **read** calls
+ (`config` GET, `status` GET) and an optional bounded health poll for managed
+ modes. Writes (`config` POST, `recall` POST) are user gestures (Save / Search).
+ No `retain`/`reflect` is ever called by the panel.
+- **Logs link** points at the existing runtime/marketplace logs surface via
+ `host.ui.navigate` — the panel does not embed or proxy log content, and the link
+ is shown only for managed modes (no managed process exists in external mode).
+- **Dormancy preserved.** A dormant pack returns clean structured signals
+ (`configured:false`, empty lists) from every route, so the panel renders a safe
+ "not configured" state with no network touched — the panel does not change the
+ provider's dormancy gate.
+
+**Theme.**
+
+- Use **only** Bobbit theme tokens / the injected lit toolkit — never hardcode
+ colours, never define a `:root` palette, never use `prefers-color-scheme`.
+- Status semantics use the semantic slots: `--positive` (connected), `--negative`
+ (unreachable), `--warning` (starting/managed-not-running), `--muted-foreground`
+ (dormant / secondary text). Reference `var(--muted-foreground)` directly (never
+ alias a surface token with a single-mode fallback).
+- All structured/recall data is rendered through the escaping lit toolkit
+ (`html`/`nothing`) — memory text, error strings, and ids are never injected as
+ raw HTML.
+- Scope all DOM/styles to the panel root; the change set touches **only** the
+ Hindsight pack panel + entrypoints + build entry + the new E2E fixture/spec — no
+ app-shell or built-in UI files.
+
+---
+
+## 9. Build & verification
+
+1. `node scripts/build-market-packs.mjs` (via `npm run build`) emits
+ `market-packs/hindsight/lib/HindsightPanel.js` (browser bundle, `lit` external).
+2. `npm run check` — typecheck (panel is `.js`, but the YAML/contribution wiring is
+ typed server-side).
+3. `npm run test:unit` then `npm run test:e2e` — the new browser spec
+ (§7) runs in the e2e phase.
+
+---
+
+## 10. Decision log
+
+- **D1 — Owner-session panel, not spawn.** The config/status surface belongs to the
+ active session; a `PanelTarget` command-palette launcher + `kind:"route"` deep
+ link match the artifacts pattern. Spawn launchers are for minting sub-agents
+ (pr-walkthrough), which this feature has none of.
+- **D2 — No new routes.** P2's `config`/`status`/`recall` are sufficient; the panel
+ is a pure client. "Recent retains" is surfaced as the `queueDepth` retry counter
+ + `lastError` because no retains-feed route exists — adding one is out of P4
+ scope.
+- **D3 — Config goes through the route, never `host.store` directly.** Validation
+ (`validateConfigOverrides`) + redaction (`redactConfig`) live server-side; a
+ direct store write would bypass both. Store-seeding stays a test-only seam.
+- **D4 — Singleton panel.** One config/status panel per session view
+ (no `instanceParam`), so the deep link needs no params and rehydrates entirely
+ from the routes.
diff --git a/docs/design/hindsight-ux-polish-implementation.md b/docs/design/hindsight-ux-polish-implementation.md
new file mode 100644
index 000000000..d3f41490f
--- /dev/null
+++ b/docs/design/hindsight-ux-polish-implementation.md
@@ -0,0 +1,504 @@
+# Hindsight UX Polish — implementation design
+
+Status: design. Owner gate: `design-doc`. Builds on the shipped Hindsight pack
+(external mode — [hindsight-pack-external.md](hindsight-pack-external.md); managed
+runtime P3; native panel P4 — [hindsight-panel-p4-implementation.md](hindsight-panel-p4-implementation.md))
+and PR #820. This document scopes — but does not write — production code/tests.
+
+It exists to fix the UX gaps found while testing PR #820:
+
+1. **Stale config form** — the panel's status card refreshes to the live config
+ (Connected + bank `hermes`) while the configuration form keeps showing the
+ mount-time defaults (empty External URL, bank `bobbit`, timeout `4000`). Save
+ can then silently clobber a good server config.
+2. **Opaque Marketplace state** — the built-in `hindsight` row only ever says
+ "Enabled", hiding the distinctions between disabled, dormant/unconfigured,
+ external connected/unreachable, and managed stopped/starting/running/unhealthy.
+3. **Missing setup guidance** — no guided walkthrough, no API-vs-UI-URL
+ explanation, no "Open Hindsight UI", no recommended-defaults explainer, no
+ explicit managed-mode Start with consent + progress.
+
+The hard invariant that frames every change: **built-in Hindsight NEVER auto-starts
+Docker on install/boot/selection** (`runtimes/hindsight.yaml: startPolicy: on-enable`).
+Every "start" path stays an explicit user gesture; every status/capability read is a
+pure read.
+
+---
+
+## 1. Root-cause analysis
+
+### 1.1 Stale form (the reproduction)
+
+`market-packs/hindsight/src/panel.js` keeps two cached snapshots per session view
+(closure `STATE` keyed by `__sessionId`):
+
+- `entry.config` — the last redacted `config` GET response.
+- `entry.draft` — the editable form values, seeded by `draftFromConfig(entry.config)`.
+
+The lifecycle today:
+
+- **Mount** (`mountKicked`): kicks `loadConfig` + `loadStatus` once. `loadConfig`
+ sets `entry.config` and re-seeds `entry.draft` (when `!dirty` is *not* checked —
+ it always overwrites).
+- **Refresh button** (`hindsight-refresh`): calls `loadStatus` **only**.
+- **Save**: `buildSaveBody` diffs `entry.draft` against `entry.config` and POSTs
+ only changed keys; on success re-seeds both from the response.
+
+The defect: **`status` and `config` re-hydrate on different triggers.** `status`
+re-reads on every Refresh / poll and reflects the *live* server config (the route
+calls `loadEffectiveConfig` fresh each time — `routes.ts::status`). `config` only
+re-reads at mount and post-Save. So once the persisted config changes after mount
+(configured via the route, by another session/agent, or simply queried before the
+record landed), the status card moves to `Connected + hermes` while the form stays
+frozen on the mount-time projection.
+
+Two distinct bugs fall out of that single divergence:
+
+- **B1 — stale display**: the form does not reflect the persisted config after a
+ Refresh.
+- **B2 — silent clobber on Save**: `buildSaveBody` diffs the draft against the
+ *stale* `entry.config` base, not the live server config. Any field the user
+ touched — or any field whose stale base differs from the live value — is sent and
+ overwrites the good record. (`{...prev, ...overrides}` server merge only protects
+ keys that are **omitted**; a stale base makes the panel send keys it should not.)
+
+### 1.2 Marketplace state opacity
+
+`renderPackActivationSummary` (`src/app/marketplace-page.ts`) labels the built-in
+row purely from the activation catalogue: `enabled === total ? "Enabled" : …`.
+It never consults the Hindsight `config`/`status` routes or `GET /api/pack-runtimes`,
+so it cannot distinguish dormant-unconfigured from external-connected from
+managed-running. The runtime consent card (`renderRuntimeConsentCardView`) discloses
+*pre-start* capability but shows no *live* runtime status and offers no Start / Stop /
+Test / Open-UI / View-logs actions.
+
+`src/app/api.ts` currently wires only `getPackRuntimeCapabilities`, `downPackRuntime`,
+`purgePackRuntime` — there is **no** `listPackRuntimes` / `PackRuntimeStatus` /
+`startPackRuntime` / `stopPackRuntime` client binding, even though the server serves
+`GET /api/pack-runtimes` and `POST /api/pack-runtimes/:id/{start,stop,restart}`.
+
+### 1.3 API URL vs UI URL
+
+`EffectiveConfig` (`market-packs/hindsight/src/shared.ts`) has `externalUrl` (the
+**data-plane API**, what the provider/client dials) but no concept of a human
+**dashboard URL**. AJ's setup: API `http://localhost:9177`; UI
+`http://localhost:19177/banks/hermes?view=data` (or the Tailscale equivalent). The
+UX must explain the two and offer "Open Hindsight UI" — which needs a new optional
+`uiUrl` config field.
+
+---
+
+## 2. Implementation partitions (non-overlapping)
+
+Partitions are carved by **file ownership** so parallel coders never touch the same
+file. `panel.js` is large and stateful, so ALL panel work is a single partition.
+
+| # | Partition | Files owned (exclusive) | Depends on |
+|---|---|---|---|
+| **A** | Hindsight panel UX | `market-packs/hindsight/src/panel.js` (+ rebuilt `lib/HindsightPanel.js`) | C (for `uiUrl`/status fields — additive, can stub) |
+| **B** | Marketplace state + runtime actions | `src/app/marketplace-page.ts`, `src/app/api.ts` (additive only) | C (status field names) |
+| **C** | Pack config schema, routes, entrypoints, manifest | `market-packs/hindsight/src/shared.ts`, `market-packs/hindsight/src/routes.ts`, `market-packs/hindsight/providers/memory.yaml`, `market-packs/hindsight/pack.yaml`, `market-packs/hindsight/entrypoints/*.yaml` | — (lands first) |
+| **E** | Browser E2E + stub | `tests/e2e/ui/hindsight-pack.spec.ts` (extend), `tests/e2e/ui/hindsight-marketplace.spec.ts` (new), `tests/e2e/hindsight-stub.mjs` (extend) | A, B, C |
+
+> No partition edits `src/ui/components/GitStatusWidget.ts`: it already renders ALL
+> `git-widget-button` launchers generically (`_renderPackLaunchers` →
+> `listLauncherEntrypoints('git-widget-button')`). Surfacing a Hindsight affordance
+> next to PR Walkthrough is therefore a pure **pack manifest** change (Partition C
+> adds a `git-widget-button` entrypoint), not a widget-code change.
+
+**Merge order:** C → (A ∥ B) → E. C is additive and contract-only, so A and B can
+develop against the documented field names before C merges.
+
+---
+
+## 3. Partition C — pack config schema, routes, entrypoints (lands first)
+
+All changes are **additive** and preserve PR #820 / P2 route contracts.
+
+### 3.1 `src/shared.ts` — add optional `uiUrl`
+
+- `EffectiveConfig`: add `uiUrl?: string` (human dashboard URL; **never** dialed by
+ the client — display/open-only).
+- `resolveConfig`: parse `uiUrl` like `externalUrl`
+ (`const uiUrl = asString(flat(raw, "uiUrl")); … ...(uiUrl ? { uiUrl } : {})`).
+- `validateConfigOverrides`: add `uiUrl` to the optional-string clear list
+ (the `["externalUrl", "apiKey", "externalDatabaseUrl", "llmApiKey"]` loop → add
+ `"uiUrl"`; it is **not** a secret, so it lives in the plain-string branch, not the
+ secret branch). Light validation: if non-empty, must parse as an `http(s)` URL.
+- `redactConfig`: echo `uiUrl` verbatim (not a secret).
+
+`uiUrl` is purely informational: it is omitted from `clientConfig` and never
+influences `isActive`/`isConfigured` — dormancy and the data-plane stay keyed on
+`externalUrl` / runtime base exactly as today.
+
+### 3.2 `providers/memory.yaml` — declare `uiUrl`
+
+Add to the `config:` block: `uiUrl: { type: string, optional: true }`. No
+`activation` change (dormancy still gates on `externalUrl`).
+
+### 3.3 `src/routes.ts::status` — surface the values the UX needs prominently
+
+Extend the `status` response `base` (additive — existing keys unchanged) so both the
+panel and the marketplace can render the active configured values without a second
+round-trip:
+
+```js
+const base = {
+ configured, mode, bank, namespace, recallScope, autoRecall, autoRetain, queueDepth,
+ // additive:
+ externalUrl: cfg.externalUrl ?? "", // data-plane API URL (non-secret)
+ uiUrl: cfg.uiUrl ?? "", // human dashboard URL (non-secret)
+ timeoutMs: cfg.timeoutMs,
+ recallBudget: cfg.recallBudget,
+ ...(err ? { lastError: err } : {}),
+};
+```
+
+`status` stays dormancy-safe (returns `healthy:false` without dialing an empty base)
+and never echoes secrets (only `externalUrl`/`uiUrl`, both non-secret).
+
+### 3.4 Entrypoints — discoverability
+
+- **Keep** `entrypoints/hindsight-palette.yaml` (command-palette) and
+ `hindsight-route.yaml` (deep link) — command-palette discoverability requirement
+ is already met.
+- **Add** `entrypoints/hindsight-git.yaml`:
+ ```yaml
+ id: hindsight.git
+ kind: git-widget-button
+ label: Hindsight Memory
+ target: { panelId: hindsight.panel }
+ ```
+ This makes the affordance appear in the git-widget "Extensions" group alongside
+ PR Walkthrough. It is a bare `PanelTarget` (opens the panel in the active session
+ via `openPackPanel`), **no `action:"spawn"`** — nothing is minted, nothing starts.
+- `pack.yaml`: append `hindsight-git` to `contents.entrypoints`
+ (`[hindsight-palette, hindsight-route, hindsight-git]`).
+
+> "Show only when relevant/configured" is intentionally **not** gated in the widget:
+> the entrypoint is registered while the pack is active, and the panel itself renders
+> the dormant/configure state. Gating the launcher on live config would require the
+> widget to call pack routes per render (rejected — keeps the widget pack-agnostic).
+> The marketplace remains the primary setup path (requirement satisfied); the
+> git-widget button is a secondary discoverability surface.
+
+### 3.5 No new pack routes
+
+The panel/marketplace use the existing `config` (GET/POST), `status` (GET),
+`recall` (POST) routes plus the server admin runtime routes
+(`/api/pack-runtimes/*`). "Test connection" == `status` GET. "Open Hindsight UI" ==
+open `uiUrl` (client-side). No `retain`/`reflect` is ever called from any UI.
+
+---
+
+## 4. Partition A — Hindsight panel UX (`market-packs/hindsight/src/panel.js`)
+
+All edits are confined to `panel.js`. Security/theme invariants from the P4 doc §8
+are preserved verbatim (no raw fetch except the existing read-only logs affordance;
+secrets write-only; no auto-mutation on mount; theme tokens only).
+
+### 4.1 Fix B1+B2 — unified refresh + dirty-aware hydration + safe Save
+
+**4.1.1 Combined refresh.** Add `refreshAll(host, key)` that calls **both**
+`loadStatus` and `loadConfig`. Rewire the Refresh button
+(`renderStatusCard` → `hindsight-refresh`) to call `refreshAll` instead of
+`loadStatus`. Mount continues to kick both (already does).
+
+**4.1.2 Dirty-aware hydration.** `loadConfig` must **not** clobber an in-progress
+edit. Change its hydration step:
+
+```js
+entry.config = res?.config ?? null;
+entry.configured = !!res?.configured;
+if (!entry.dirty) { // only re-seed when the user has no unsaved edits
+ entry.draft = draftFromConfig(entry.config);
+ entry.secretTouched = { apiKey:false, externalDatabaseUrl:false, llmApiKey:false };
+}
+entry.configState = "ready";
+```
+
+When `dirty`, the user's draft is preserved AND `entry.config` is still updated to
+the fresh server snapshot — which fixes B2 because Save now diffs against the live
+base (see 4.1.3). This satisfies the requirement: *"form fields must reflect the
+persisted config, unless the user has unsaved edits."*
+
+**4.1.3 Safe Save (no silent clobber).** Before building the POST body, re-read the
+live config so the diff base is never stale:
+
+```js
+async function save(host, key) {
+ const entry = get(key);
+ if (!entry?.draft || entry.saving) return;
+ entry.saving = true; entry.saveErrors = []; repaint(host);
+ // Refresh the diff base from the server so a stale snapshot cannot send keys that
+ // overwrite a good config (B2). Does NOT touch the draft (dirty-aware loadConfig).
+ await loadConfig(host, key);
+ const body = buildSaveBody(get(key)); // diffs draft against the JUST-fetched config
+ …POST as today…
+}
+```
+
+`buildSaveBody` is unchanged — it already sends only keys where `draft !== config`.
+With a fresh base, an untouched field that equals the live value is omitted (server
+preserves it); only genuinely user-changed fields are sent.
+
+**4.1.4 Unsaved-changes banner + Discard.** In `renderConfigCard`, when
+`entry.dirty`, render an inline banner (`data-testid="hindsight-unsaved"`) with a
+**Discard** button (`hindsight-discard`) that re-seeds the draft from `entry.config`
+and clears `dirty`/`secretTouched`. This makes the "unless the user has unsaved
+edits" rule visible and reversible.
+
+`freshEntry()` already initialises `dirty:false`; no shape change needed beyond the
+above behaviour.
+
+### 4.2 Surface the active configured values prominently
+
+Extend `renderStatusCard`'s `
` to include the values now returned
+by `status` (§3.3), reading status-first with config fallback:
+
+- **Data-plane URL** — `s.externalUrl` (label "API URL"), with the API-vs-UI
+ explainer (4.3). For managed modes show "managed runtime (loopback)".
+- **UI URL** — `s.uiUrl` rendered as an **Open Hindsight UI** link
+ (`data-testid="hindsight-open-ui"`, `target=_blank rel=noopener`) when non-empty.
+- **Timeout** — `s.timeoutMs`. **Recall budget** — `s.recallBudget`.
+- Already present: mode, bank, namespace, recallScope, auto recall/retain,
+ `queueDepth` chip, `lastError`.
+
+These are read-only projections — no new writes.
+
+### 4.3 API URL vs UI URL explainer
+
+In `renderConfigCard`, the `externalUrl` field hint already exists; extend it and add
+a sibling `uiUrl` field:
+
+- `hindsight-external-url` hint: *"API / data-plane URL Bobbit calls to recall &
+ retain (e.g. http://localhost:9177). Activates external mode; empty keeps it
+ dormant."*
+- New `renderField("Dashboard UI URL", "hindsight-ui-url", d.uiUrl, …, { hint:
+ "Optional human dashboard opened by 'Open Hindsight UI' — never called by Bobbit
+ (e.g. http://localhost:19177/banks/hermes?view=data)." })`.
+
+Add `uiUrl: asText(c.uiUrl, "")` to `draftFromConfig` and `"uiUrl"` to the
+non-secret loop in `buildSaveBody`.
+
+### 4.4 Guided setup walkthrough
+
+Add a **Setup** section rendered above the config card when
+`!entry.configured` (first-run) OR when the user clicks a "Setup guide" toggle. It is
+a small step machine in panel state (`entry.setup = { step, … }`), not a new route.
+All steps are pure projection + user-gesture writes.
+
+**Mode chooser (step 1)** with recommended-defaults explainer
+(`data-testid="hindsight-setup"`):
+
+- **External** *(recommended when you already run Hindsight, e.g. Hermes-local)* —
+ fields: API URL, optional UI URL, namespace, bank, optional API key, recall/retain
+ toggles, timeout. Validate-on-next: API URL parses as `http(s)`.
+- **Managed (Bobbit-run, managed Postgres)** — required: LLM API key, data dir.
+- **Managed + external Postgres** — required: external Postgres URL, LLM API key.
+
+**Recommended-defaults explainer** (`data-testid="hindsight-defaults-explainer"`):
+local/private data by default; shared `bobbit` bank unless connecting to an existing
+bank like `hermes`; async retain on; auto-recall on; conservative `4000ms` timeout;
+"bring your own LLM key for managed extraction — never hardcoded".
+
+**Clear "who manages what" matrix** (`data-testid="hindsight-ownership"`): four rows —
+(1) Bobbit-managed Docker runtime, (2) Bobbit-managed Hindsight + external Postgres,
+(3) existing external Hindsight data-plane, (4) Hermes-local embedded — each naming
+what Bobbit manages vs what the user manages.
+
+**Step validation + progress (external).** Next/validate per step; final step runs a
+**connection test** (`status` GET → healthy) then a **first recall/retain smoke
+test** (`recall` GET with a probe query; retain is **not** auto-fired — display "auto
+retain happens on your next turn" to honour no-unsolicited-writes). Render a progress
+list with `data-testid="hindsight-setup-progress"` and per-step states
+(pending/running/ok/fail).
+
+### 4.5 Managed mode — explicit Start, consent, progress (NO auto-start)
+
+The P4 panel writes **config only** and never starts Docker. This is preserved and
+made explicit:
+
+- **Selecting `managed` / `managed-external-postgres` does NOT start anything.** Mode
+ is a local draft change; Save persists config only.
+- Add an explicit **Start runtime** button (`hindsight-start-runtime`) shown for
+ managed modes once `configured`, gated behind a **consent disclosure**
+ (`hindsight-managed-consent`) that lists: required inputs present? (LLM key /
+ external PG URL / data dir), images/services, loopback ports, data path, and "this
+ starts local Docker containers". The button is enabled only when required inputs
+ are set; clicking it is the explicit start gesture.
+- Start dispatches `POST /api/pack-runtimes/:id/start` via the existing authed
+ gateway fetch the panel already uses for logs (`gatewayBase()`/`gatewayToken()`,
+ `RUNTIME_API_ID`) — the only raw-gateway seam, confined to runtime admin actions.
+ After start, the existing bounded health poll (`maybePoll`) flips the badge to
+ Connected; render a **progress list** (`hindsight-runtime-progress`) driven by
+ status transitions (image pull / container start / health) using the runtime
+ status + logs the panel already reads.
+- A **Stop runtime** button (`hindsight-stop-runtime`) → `POST …/stop`.
+
+> Invariant enforcement in this partition: there is **no** `start`/`compose up` call
+> on mount, on mode-select, or inside Save. The only start path is the
+> `hindsight-start-runtime` click handler. The E2E (§6) asserts this.
+
+### 4.6 Test hooks added
+
+`hindsight-unsaved`, `hindsight-discard`, `hindsight-ui-url`, `hindsight-open-ui`,
+`hindsight-setup`, `hindsight-defaults-explainer`, `hindsight-ownership`,
+`hindsight-setup-progress`, `hindsight-managed-consent`, `hindsight-start-runtime`,
+`hindsight-stop-runtime`, `hindsight-runtime-progress`. Existing hooks unchanged.
+
+---
+
+## 5. Partition B — Marketplace state + runtime actions
+
+### 5.1 `src/app/api.ts` — additive runtime bindings
+
+Add (mirroring the existing `getPackRuntimeCapabilities` style and
+`encodeRuntimeApiId`):
+
+```ts
+export interface PackRuntimeStatus {
+ packId: string; runtimeId: string;
+ state: "running" | "starting" | "stopped" | "unhealthy" | "unknown";
+ healthy?: boolean; startPolicy?: string; mode?: string;
+ ports?: PackRuntimePortInfo[]; lastError?: string;
+}
+export function listPackRuntimes(projectId?: string): Promise>;
+export function startPackRuntime(opts: { packId; runtimeId; projectId? }): Promise>; // POST …/start
+export function stopPackRuntime(opts: { packId; runtimeId; projectId? }): Promise>; // POST …/stop
+```
+
+> Confirm the exact server `PackRuntimeStatus` shape against
+> `GET /api/pack-runtimes` (`pack-runtime-supervisor.ts`) and mirror it — do not
+> invent fields. Adjust the union to the server's literal `state`/`status` values.
+
+Also add a tiny pack-route reader so the marketplace can read Hindsight config/status
+the same way the panel does (the marketplace is app-realm, not a panel host, so it
+uses the REST surface directly):
+
+```ts
+// POST /api/ext/route/:name on the active session/pack scope; GET-style routes use {method:"GET"}.
+export function callHindsightRoute(name: "config"|"status", init): Promise>;
+```
+
+(Resolve the precise `/api/ext/route/:name` request shape from `server.ts` §"POST
+/api/ext/route/:name" — it is pack-scoped via headers; reuse the same auth the other
+`marketFetch` calls use.)
+
+### 5.2 `src/app/marketplace-page.ts` — derived state label + actions
+
+**5.2.1 State derivation.** Replace the built-in Hindsight row's label logic (only)
+with a derivation that combines:
+
+- activation (`activationByPack`) → **Disabled** when the pack/runtime toggle is off;
+- `status` route (`configured`, `mode`, `healthy`) → **Dormant** (configured:false),
+ **External connected** / **External unreachable** (external mode), and
+- `listPackRuntimes` for the `hindsight` runtime → **Managed stopped / starting /
+ running / unhealthy**.
+
+Add a module cache `hindsightStatus` + `hindsightRuntimes`, fetched best-effort in
+`loadMarketplaceData` (alongside the existing background loads) and invalidated the
+same way `invalidateRuntimeCapabilities` is. Render the derived state as a badge
+(`data-testid="market-hindsight-state"`, `data-state=…`) on the built-in row. This
+is **read-only** — fetching status/runtime list never starts Docker.
+
+> Scope the derivation to the built-in `hindsight` pack row only; generic packs keep
+> the existing `Enabled/Disabled/Partially enabled` summary. Implement as a small
+> branch in `renderBuiltinPackCard` that, when `pack.packName === "hindsight"`,
+> renders the richer state header + action bar in addition to the activation toggles.
+
+**5.2.2 Actions.** Add an action bar on the built-in Hindsight row
+(`data-testid="market-hindsight-actions"`):
+
+- **Configure** → open the panel (`runLauncherEntrypoint(launcherKey("hindsight","hindsight.palette"))`
+ or `openPackPanel`). Primary setup path.
+- **Test connection** → `status` GET; show a transient ok/fail lozenge.
+- **Open Hindsight UI** → open `status.uiUrl` in a new tab (hidden when empty).
+- **Start runtime** / **Stop runtime** → `startPackRuntime`/`stopPackRuntime`
+ (managed modes only; Start reuses the existing consent card
+ `renderRuntimeConsentCardView` as the disclosure, and remains an explicit click).
+- **View logs** → reuse the runtime logs route (managed only) — link to the existing
+ logs surface or open the panel's logs view.
+
+All actions are explicit clicks. The consent card already gates managed-runtime
+disclosure; Start stays the only Docker-starting path and is never auto-invoked.
+
+### 5.3 No regression to activation semantics
+
+The existing master toggle / per-entity toggles and the `runtimes` consent card are
+untouched in behaviour. The derived state + action bar are **additive** UI on the
+built-in row. `handleToggleAllActivation` already covers the `runtimes` array so the
+master OFF still stops the managed runtime.
+
+---
+
+## 6. Partition E — Browser E2E + stub
+
+Extend `tests/e2e/hindsight-stub.mjs` minimally (config-snapshot helper) and the
+runtime supervisor mock seam (`registerPackRuntimeSupervisorFactory`, already used by
+runtime E2E) so runtime status/events are **mocked/stubbed** — *no real Docker in
+the e2e phase* (real Docker stays in `tests/manual-integration/`).
+
+New/extended specs:
+
+| Spec | Scenario | Key asserts |
+|---|---|---|
+| `hindsight-pack.spec.ts` (extend) | **Stale-form refresh regression (B1/B2)** | Mount dormant → seed config server-side out-of-band → click **Refresh** → form reflects persisted external URL + bank + timeout (not defaults). Then with an unsaved edit, Refresh keeps the edit; Save diffs against the live base and does not clobber untouched keys. |
+| `hindsight-pack.spec.ts` (extend) | **Open Hindsight UI** | Save `uiUrl` → `hindsight-open-ui` visible with correct `href`; absent when empty. |
+| `hindsight-pack.spec.ts` (extend) | **Guided setup defaults/explanations** | First-run shows `hindsight-setup` + `hindsight-defaults-explainer` + `hindsight-ownership`; external step validates URL; connection-test progress reaches ok against the healthy stub. |
+| `hindsight-pack.spec.ts` (extend) | **Managed no-auto-start** | Select `managed`, fill LLM key, Save → assert **no** `POST /api/pack-runtimes/:id/start` fired. Click `hindsight-start-runtime` → exactly one `/start` request; progress + badge advance via mocked runtime status. |
+| `hindsight-marketplace.spec.ts` (new) | **First-run Marketplace configure path** | Built-in row shows `market-hindsight-state` = Dormant; **Configure** opens the panel. |
+| `hindsight-marketplace.spec.ts` (new) | **External connected state** | With healthy stub configured, row state = External connected; **Test connection** ok; **Open Hindsight UI** href = `uiUrl`. |
+| `hindsight-marketplace.spec.ts` (new) | **Managed status rendering (mocked runtime events)** | Mocked supervisor reports stopped→starting→running; row state tracks it; **Start**/**Stop** call the right routes; selecting managed / loading the page never fires `/start` (no-auto-start). |
+
+Skip-guards mirror the existing `DEPS_READY` + `resolveHindsightContribution`
+pattern so the e2e phase stays green before the branches merge.
+
+---
+
+## 7. Invariant & contract checklist
+
+- **No-Docker-auto-start (hard):** the ONLY start paths are the panel
+ `hindsight-start-runtime` click (Partition A §4.5) and the marketplace **Start**
+ button (Partition B §5.2.2). Mount, mode-select, Save, status/capability reads, and
+ marketplace load never call `/start` or `compose up`. Pinned by E2E §6
+ (managed-no-auto-start) and preserved `startPolicy: on-enable`.
+- **Secrets write-only:** `uiUrl`/`externalUrl` are non-secret and echoed; `apiKey`/
+ `externalDatabaseUrl`/`llmApiKey` stay `*Set`-only. No new field is a secret.
+- **PR #820 / P2 contracts preserved:** all route changes are additive
+ (`status` gains non-secret fields; new optional `uiUrl`). No route removed/renamed.
+- **Data-plane target unchanged:** Bobbit dials the API (`externalUrl` / runtime
+ base) only; `uiUrl` is open-only and never dialed.
+- **Theme/security:** panel keeps theme-token-only styling and the single read-only
+ raw-gateway seam (now also used for explicit start/stop admin actions); all data
+ still flows through the Host API / documented REST routes.
+
+## 8. Build & verification
+
+1. `node scripts/build-market-packs.mjs` (via `npm run build`) re-emits
+ `lib/HindsightPanel.js` after Partition A.
+2. `npm run check`.
+3. `npm run test:unit` then `npm run test:e2e` (new/extended browser specs run in the
+ e2e phase, runtime mocked).
+4. `tests/manual-integration/hindsight-*` remains the only real-Docker path.
+
+## 9. Decision log
+
+- **D1 — One partition owns `panel.js`.** It is large and stateful; splitting it
+ across coders would conflict. Marketplace, pack-config, and tests live in disjoint
+ files and parallelise cleanly.
+- **D2 — Fix the stale form by unifying refresh + dirty-aware hydration + fresh Save
+ base**, not by re-architecting. Refresh already re-reads status; making it also
+ re-read config (gated on `!dirty`) and diffing Save against the just-fetched config
+ closes both B1 and B2 with minimal surface.
+- **D3 — `uiUrl` is a new optional, non-secret config field** — the cleanest way to
+ separate the API/data-plane URL from the human dashboard and power "Open Hindsight
+ UI", without touching dormancy or the client dial path.
+- **D4 — Git-widget affordance via a pack entrypoint, not widget code.** The widget
+ already renders `git-widget-button` launchers generically; a manifest entry is the
+ whole change. Marketplace stays the primary setup path.
+- **D5 — Marketplace runtime status is read-only derivation;** Start/Stop are
+ explicit additive actions reusing the existing consent card. No change to
+ activation semantics or the no-auto-start guarantee.
+
+
diff --git a/docs/design/hindsight-ux-polish.md b/docs/design/hindsight-ux-polish.md
new file mode 100644
index 000000000..aabbae215
--- /dev/null
+++ b/docs/design/hindsight-ux-polish.md
@@ -0,0 +1,516 @@
+# Hindsight UX Polish — design doc
+
+Status: design / UX spec. Owner gate: `design-doc`. Scope is the **UX** of the
+Hindsight memory extension across two surfaces — the **Marketplace installed
+row** (primary setup path) and the **native Hindsight panel** (config / status /
+search) — plus a **guided setup walkthrough** for both Bobbit-managed and
+self-managed/external deployments. It fixes the panel **stale-form** regression
+and adds the missing state/action vocabulary the goal calls out.
+
+Companion interactive prototype: [`hindsight-ux-polish.prototype.html`](./hindsight-ux-polish.prototype.html)
+(self-contained; open in a browser, or via the preview panel). The prototype IS
+the visual spec — it demonstrates every row state, the wizard flow, and all
+interaction states with Bobbit theme tokens.
+
+This doc changes **no production code or tests**. It is the implementation
+contract for the follow-on `implementation` gate. It builds on and must preserve:
+
+- [hindsight-pack-external.md](./hindsight-pack-external.md) — external client/provider, dormancy gate, config schema.
+- [hindsight-panel-p4-implementation.md](./hindsight-panel-p4-implementation.md) — the native panel (P4), `config`/`status`/`recall` routes.
+- [pack-based-marketplace.md](./pack-based-marketplace.md) — installed rows, activation toggles, the managed-runtime consent enable-card (§8).
+- `market-packs/hindsight/runtimes/hindsight.yaml` — `startPolicy: on-enable` (Docker never auto-starts).
+
+---
+
+## 0. Problem statement (observed)
+
+1. **Stale form (the headline bug).** After Hindsight is configured externally
+ (`http://localhost:9177`, bank `hermes`, timeout `15000`, auto-retain on) the
+ panel's **status card** correctly shows `Connected` + `hermes` after Refresh,
+ but the **configuration form** still shows empty External URL, bank `bobbit`,
+ timeout `4000`. Pressing **Save** would diff those stale defaults against the
+ persisted config and **overwrite the good config**.
+
+ Root cause (panel.js): `loadConfig` runs **once** behind the `mountKicked`
+ guard; the **Refresh** button calls only `loadStatus`. If config is persisted
+ by any path after the panel mounted (the marketplace, a deep-link in another
+ view, the API), the form never re-hydrates, and `buildSaveBody` diffs the
+ user's draft against the stale `entry.config` baseline — so Save ships
+ stale values as "changes".
+
+2. **Flat Marketplace state.** The built-in `hindsight` installed row collapses
+ to a single `Enabled` lozenge (the generic master toggle). It hides the six
+ distinctions that actually matter for memory: Disabled, Dormant/unconfigured,
+ External connected, External unreachable, Managed stopped/starting/running/
+ unhealthy.
+
+3. **Setup is undiscoverable and unguided.** The Marketplace is meant to be the
+ primary setup path but offers no Configure / Test / Open-UI / Start / Stop /
+ Logs actions and no guided walkthrough. Users fall back to the command palette
+ and hand-edit fields with no explanation of API-URL vs UI-URL, recommended
+ defaults, or what "managed" will actually start.
+
+---
+
+## 1. Design principles for this surface
+
+- **The Marketplace row is the front door; the panel is the workbench.** The row
+ tells you *what state Hindsight is in* and offers the next safe action. The
+ panel is where you do detailed config, search, and read logs.
+- **State is never ambiguous.** Every row resolves to exactly one badge with a
+ semantic colour, an icon (colour is never the only signal), and a one-line
+ plain-language explanation.
+- **No surprise side effects.** Selecting "managed" must never start Docker.
+ Starting Docker is always a separate, explicit, consented action with a
+ disclosure of exactly what will run.
+- **Persisted config is the source of truth.** The UI shows what is actually
+ stored, refreshes both config and status together, and never lets a stale
+ draft silently overwrite a good config.
+- **Reuse the existing grammar.** Match the marketplace toggle/badge/card
+ primitives, the panel's `hs-*` classes, and Bobbit theme tokens exactly — no
+ new palette, no `prefers-color-scheme`.
+
+---
+
+## 2. State model — the single source of truth
+
+Both surfaces derive their badge from the **same** function of three inputs:
+`mode`, `configured`, and `healthy` (+ a managed `runtimeStatus` when present).
+This must be one shared helper so the row and the panel can never disagree.
+
+```
+deriveHindsightState({ disabled, mode, configured, healthy, runtimeStatus }) → State
+```
+
+| # | State | Trigger | Badge | Token | Icon | One-liner |
+|---|---|---|---|---|---|---|
+| 1 | **Disabled** | pack/provider toggled off | `Disabled` | `--muted-foreground` | `power-off` | "Hindsight memory is turned off." |
+| 2 | **Dormant** | enabled, external mode, no `externalUrl` (or managed not yet configured) | `Not configured` | `--muted-foreground` | `circle-dashed` | "No memory backend configured yet." |
+| 3 | **External · Connected** | external, `externalUrl` set, `healthy` | `Connected` | `--positive` | `check-circle` | "Connected to your Hindsight at {host}." |
+| 4 | **External · Unreachable** | external, `externalUrl` set, `!healthy` | `Unreachable` | `--negative` | `alert-triangle` | "Can't reach Hindsight at {host}." |
+| 5 | **Managed · Stopped** | managed, configured, runtime `stopped` | `Stopped` | `--muted-foreground` | `square` | "Managed runtime is stopped." |
+| 6 | **Managed · Starting** | managed, runtime `starting` (or `configured && !healthy` while poll runs) | `Starting…` | `--warning` | `loader` (spin) | "Managed runtime is starting…" |
+| 7 | **Managed · Running** | managed, runtime `running` + `healthy` | `Running` | `--positive` | `check-circle` | "Managed runtime is running." |
+| 8 | **Managed · Unhealthy** | managed, runtime up but health probe failing | `Unhealthy` | `--negative` | `alert-triangle` | "Managed runtime is up but not healthy." |
+
+Notes:
+- States 5–8 require runtime context. The Marketplace row has it via
+ `GET /api/pack-runtimes?projectId=` (`PackRuntimeStatus`); the panel infers
+ `starting` vs `running` from `status.healthy` + its bounded managed-mode poll.
+- The panel's existing `deriveBadge` collapses 5/6/8 into one "Starting" badge.
+ This spec **splits** them using the runtime status the marketplace already
+ fetches; the panel gets the same via a new `runtimeStatus` field on the
+ `status` route response (additive, see §7) **or** by reading
+ `GET /api/pack-runtimes` directly (it already does for logs). Prefer the
+ additive `status` field to keep the panel a pure Host-API client.
+
+The mapping is captured visually in the prototype's "State gallery".
+
+---
+
+## 3. Marketplace installed row — redesign
+
+### 3.1 Anatomy
+
+The built-in `hindsight` row keeps the existing card chrome (`market-pack-card`,
+built-in badge, version, description) and the master enable toggle, and **adds a
+Hindsight status strip** between the description and the activation controls:
+
+```
+┌ hindsight [Built-in] v1.0.0 [ Enabled ⃝ ]│ ← master toggle (unchanged)
+│ Persistent agent memory backed by Hindsight… │
+│ ─────────────────────────────────────────────────────────────────── │
+│ ◍ Connected External · http://localhost:9177 │ ← NEW status strip (state §2)
+│ Bank hermes · ns default · recall all · auto-recall on · auto-retain on│ ← active config summary (§4)
+│ [ Configure ] [ Test connection ] [ Open Hindsight UI ↗ ] │ ← action row (§5), state-aware
+└───────────────────────────────────────────────────────────────────── ┘
+```
+
+The strip renders only for the built-in `hindsight` pack (generic packs keep the
+plain activation list). It is driven by a small adapter that reads the pack's
+`status` route + `pack-runtimes` status, so it is **Hindsight-specific UI hung
+off a generic seam**, not a change to every pack's row.
+
+### 3.2 Badge placement & semantics
+
+- The badge sits left of the strip, using the §2 token + icon. It replaces the
+ ambiguous reliance on the generic `Enabled` master lozenge for *memory* state.
+- The master toggle still means "is the pack/provider active at all" (State 1 vs
+ the rest). When **off**, the strip shows only State 1 and the action row
+ collapses to a single muted "Enable to configure" hint.
+- When the pack is enabled but dormant (State 2), the badge is muted and the
+ **primary** action is `Configure` (opens the guided setup, §6).
+
+### 3.3 Managed runtime rows stay consent-gated
+
+The existing managed-runtime consent enable-card (`renderRuntimeConsentCard`,
+§8 of pack-based-marketplace) is unchanged in contract: the runtime toggle is the
+explicit on-enable start, and the disclosure (services, ports, volume path,
+trust copy) renders inline before it. The new status strip **adds** Start / Stop
+/ Restart / View-logs actions (§5) that drive the existing
+`/api/pack-runtimes/:id/{start,stop,restart,logs}` routes — it does not bypass
+the consent card. Selecting managed mode in Configure writes config only; the
+runtime stays Stopped (State 5) until the user explicitly starts it.
+
+---
+
+## 4. Active configuration summary
+
+Surface the live, persisted config compactly on both surfaces (goal requirement).
+Source: the `config` GET (redacted) + `status` GET. Render as a single muted line
+on the marketplace strip and as the existing `hs-rows` dl in the panel, extended
+to include every value:
+
+| Field | Marketplace strip | Panel status card |
+|---|---|---|
+| Data-plane API URL | `External · {externalUrl}` / `Managed · 127.0.0.1:{port}` | dedicated row, monospace |
+| UI / dashboard URL | link chip "Open Hindsight UI ↗" (see §8) | row with the resolved UI URL + copy button |
+| Bank | `Bank {bank}` | row |
+| Namespace | `ns {namespace}` | row |
+| Recall scope | `recall {all\|project}` | row |
+| Auto-recall / auto-retain | `auto-recall on · auto-retain on` | row (existing) |
+| Timeout | (omitted on strip) | row `{timeoutMs} ms` |
+| Queue depth | chip when `>0` | existing `queueDepth` chip |
+| Last error | — | existing muted `lastError` line |
+
+Secrets are never shown — only `apiKeySet` etc. as a "key set" chip.
+
+---
+
+## 5. Action inventory (state-aware)
+
+Each action is shown **only** in states where it is meaningful. All actions map
+to existing routes; none requires new server endpoints except the optional
+`status.runtimeStatus`/`uiUrl` additive fields (§7).
+
+| Action | Visible in states | Effect | Backing call |
+|---|---|---|---|
+| **Configure** | 1(disabled-hint only)·2·3·4·5·7·8 | Opens guided setup (§6) seeded with current config | opens panel / wizard; persists via `config` POST |
+| **Test connection** | 3·4·5·6·7·8 | Runs a one-shot health + recall smoke test, shows inline result | `status` GET (health) + `recall` POST (smoke) |
+| **Open Hindsight UI ↗** | 3·7·8 (any time a UI URL is known) | Opens the operator dashboard in a new tab | anchor to resolved `uiUrl` (§8) |
+| **Start runtime** | 5 (managed stopped) | Explicit consented start | `POST /api/pack-runtimes/:id/start` |
+| **Stop runtime** | 6·7·8 (managed up) | Stops containers, keeps data | `POST /api/pack-runtimes/:id/stop` |
+| **Restart runtime** | 7·8 (managed up) | Restart containers | `POST /api/pack-runtimes/:id/restart` |
+| **View logs** | 5·6·7·8 (managed) | Inline log tail (existing panel affordance) | `GET /api/pack-runtimes/:id/logs?tail=` |
+
+Layout rules:
+- At most **three** buttons inline; overflow into a "⋯ More" menu (matches the
+ mobile-action-menus pattern). External mode never shows Start/Stop/Logs (no
+ Bobbit-managed process — mirror the existing `RUNTIME_EXTERNAL_GUIDANCE`).
+- Destructive/heavy actions (Start a Docker runtime, Stop) get a confirm step
+ reusing `confirmAction`; Start additionally routes through the consent card.
+- Busy state per action (spinner on the button, disabled siblings) reusing the
+ existing `busy` set keyed `hs-action:{id}`.
+
+---
+
+## 6. Guided setup walkthrough
+
+A modal/inline wizard launched from **Configure** (Marketplace) or the panel's
+"Set up Hindsight" CTA when dormant. It explains choices, recommends safe
+defaults, validates each step, and shows live progress for runtime actions. It
+writes through the **same** `config` route + `pack-runtimes` routes — it is a
+guided wrapper over the existing surface, not a new config store.
+
+### 6.1 Step 0 — Choose a deployment
+
+A single decision screen with four cards mapping to what Bobbit manages vs what
+the user manages (goal requirement):
+
+| Card | mode | Bobbit manages | You manage | When |
+|---|---|---|---|---|
+| **Bobbit-managed (recommended)** | `managed` | Docker: Hindsight API + Postgres | An LLM API key; a data dir | "I just want memory to work locally." |
+| **Bobbit-managed + your Postgres** | `managed-external-postgres` | Docker: Hindsight API | Postgres URL; LLM key | "I have a Postgres I want to use." |
+| **Connect existing Hindsight** | `external` | Nothing (client only) | The whole Hindsight deployment | "I already run Hindsight (e.g. Hermes)." |
+| **Hermes-local / embedded** | `external` (preset) | Nothing | Hermes runs Hindsight for you | AJ's setup — preset URL `http://localhost:9177`, bank `hermes`. |
+
+Each card states the consent up-front: the two managed cards carry a "Starts
+local Docker containers when you press Start" note; the external cards say "No
+Docker — Bobbit only talks to a URL you provide."
+
+### 6.2 Self-managed / external branch
+
+Steps:
+1. **API URL** — `externalUrl`. Help text: *"The Hindsight data-plane API. This
+ is where Bobbit reads and writes memory."* AJ example placeholder
+ `http://localhost:9177`. Validation: must be a URL; live "Checking…" → green
+ check on a successful `/health`, red on failure (does not block Save).
+2. **Optional dashboard / UI URL** — `uiUrl` (new optional config field, §7).
+ Help: *"The Hindsight web UI for browsing memory. Optional — used only for the
+ 'Open Hindsight UI' link."* AJ example
+ `http://localhost:19177/banks/hermes?view=data` (and Tailscale equivalent).
+ Explicit copy distinguishing **API URL vs UI URL** (§8).
+3. **Bank & namespace** — `bank` (default `bobbit`; AJ → `hermes`), `namespace`
+ (default `default`). Help explains the shared-bank model (one tag-scoped bank).
+4. **API key** (optional) — write-only secret.
+5. **Recall/retain & limits** — auto-recall, auto-retain toggles, recall scope,
+ timeout. Recommended defaults pre-filled (§9) with inline rationale.
+6. **Smoke test** — a single "Test & finish" that runs health → recall → retain →
+ recall round-trip, rendering each as a progress row (✓/✗). Failure is
+ non-blocking; the wizard still saves and explains what's degraded.
+
+### 6.3 Bobbit-managed branch
+
+Steps:
+1. **Consent & what-will-run** — the existing capability disclosure (services
+ `api`,`db`; allocated loopback ports; volume path; trust copy). Explicit:
+ *"Nothing starts yet. Bobbit will start these containers only when you press
+ Start at the end."*
+2. **LLM API key** (required to start) — write-only secret → runtime
+ `HINDSIGHT_API_LLM_API_KEY`. Inline note that it never leaves the host config.
+3. **Data dir** (`managed`) or **Postgres URL** (`managed-external-postgres`,
+ required) — with the AJ-safe default `~/.hindsight`.
+4. **Bank & namespace / limits** — as external, recommended defaults.
+5. **Start runtime** — the explicit Start button. Renders a **progress
+ timeline** driven by the runtime status transitions + logs poll:
+ `Pull image → Create containers → Start → Health check → First recall/retain
+ smoke test`. Each step is a row with pending/active(spinner)/done/failed
+ states. The timeline reads `GET /api/pack-runtimes?…` status + `/logs` (no new
+ route); in normal E2E these events are **mocked/stubbed** (no real Docker).
+
+### 6.4 Progress & validation contract
+
+- Every step has a **validate-on-advance** gate (URL well-formed, required
+ secret present for the chosen mode) surfaced inline; the wizard never advances
+ past an invalid required field but **never blocks** on a failing health probe
+ (degraded-but-saved is valid).
+- The wizard's "Save" writes only **changed** keys (same diff discipline as the
+ panel) so it can't clobber unrelated config.
+- The progress timeline is a pure projection of runtime status/log events; on
+ failure it shows the failing step + the relevant log tail + a Retry that
+ re-issues the same start call.
+
+---
+
+## 7. Stale-form fix (the regression)
+
+This is a UX + state-machine fix in `market-packs/hindsight/src/panel.js`. No
+route changes are required for the core fix.
+
+### 7.1 Refresh re-hydrates BOTH config and status
+
+`Refresh` (and the post-Save refresh, and the marketplace strip's reload) must
+call `loadConfig` **and** `loadStatus`. The status card and the form must always
+reflect the same load generation.
+
+### 7.2 Reconcile draft against freshly-loaded config (dirty-aware)
+
+On every `loadConfig` resolution:
+
+- If the user has **no unsaved edits** (`dirty === false`): **reseed the draft**
+ from the freshly-loaded redacted config (current behaviour only runs at mount;
+ extend it to every load). This alone fixes the observed repro.
+- If the user **has unsaved edits** (`dirty === true`) **and** the loaded config
+ differs from the baseline the draft was seeded from: **do not silently
+ overwrite**. Show a non-destructive banner in the config card:
+
+ > "Configuration changed on the server since you started editing.
+ > [Reload] discards your edits · [Keep editing] keeps them."
+
+ `Reload` reseeds the draft + clears dirty; `Keep editing` keeps the draft and
+ pins the baseline to the newly-loaded config so a later Save diffs correctly.
+
+### 7.3 Save never diffs against a stale baseline
+
+`buildSaveBody` must diff against the **last loaded** config, and a Save must
+first ensure `entry.config` is fresh:
+
+- Before sending, re-`GET config`; if it changed since the draft baseline and the
+ user hasn't acknowledged, show the §7.2 banner instead of saving (optimistic
+ concurrency, last-load-wins is unacceptable for a memory backend URL).
+- After a successful Save, reseed draft + clear dirty (existing) **and** reload
+ status (existing). Add: reload config too so the redacted `*Set` chips refresh.
+
+### 7.4 Regression test (for the tester gate)
+
+Browser E2E: mount panel (dormant) → persist a config to `hermes`/`15000`/
+`http://localhost:9177` via the `config` route out-of-band → click **Refresh** →
+assert the **form** fields (`hindsight-external-url`, `hindsight-bank`,
+`hindsight-timeout`) now reflect the persisted values, not the defaults → assert
+a subsequent **Save** with no edits sends an **empty** diff body (no clobber).
+Plus the dirty-aware path: edit a field, push an out-of-band change, Refresh →
+assert the "changed on server" banner renders and the user's edit is preserved.
+
+### 7.5 Optional additive route fields
+
+To let the panel split managed states (§2) and render the UI-URL link without a
+raw `pack-runtimes` read, add **optional** fields to the `status`/`config`
+contracts (additive, backward-compatible):
+
+- `status.runtimeStatus?: "stopped"|"starting"|"running"|"unhealthy"` — mirrors
+ the supervisor status for managed modes (absent in external mode).
+- `config.uiUrl?: string` — operator dashboard URL (external/managed); redacted
+ surface unchanged (not a secret). Drives "Open Hindsight UI" (§8).
+
+These are the **only** contract additions and both are optional; the core
+stale-form fix does not depend on them.
+
+---
+
+## 8. API URL vs UI/dashboard URL — copy & examples
+
+Users conflate the data-plane API (what Bobbit talks to) with the human web UI.
+Make the distinction explicit everywhere a URL appears.
+
+- **API URL** (`externalUrl`): *"The Hindsight data-plane API — where Bobbit
+ reads and writes memory. Usually port 9177 locally."*
+ AJ example: `http://localhost:9177`.
+- **UI / dashboard URL** (`uiUrl`, optional): *"The Hindsight web dashboard for
+ browsing your memory. Bobbit never reads through this — it's just a convenience
+ link."*
+ AJ examples:
+ - Local: `http://localhost:19177/banks/hermes?view=data`
+ - Tailscale: `http://:19177/banks/hermes?view=data`
+
+Rules:
+- The panel and the marketplace strip show the **API URL** as the primary
+ identity ("External · http://localhost:9177").
+- "Open Hindsight UI ↗" appears only when a `uiUrl` is known (or, for managed
+ mode, can be derived from the allocated web port). It opens in a new tab; it is
+ never used for data calls.
+- If `uiUrl` is unset, the action is hidden and the wizard step explains it is
+ optional. Never fabricate a UI URL from the API URL (different port/path).
+
+---
+
+## 9. Recommended defaults (explainer)
+
+Surface a "Recommended defaults" explainer in the wizard (and as a `?` popover on
+the panel) stating Bobbit's opinionated, safe defaults and the rationale:
+
+| Setting | Default | Rationale (shown) |
+|---|---|---|
+| Data locality | local / private | "Your memory stays on your machine unless you point at a shared deployment." |
+| Bank | `bobbit` (shared) | "One shared, tag-scoped bank. Use an existing bank like `hermes` only when connecting to one." |
+| Namespace | `default` | "Leave as `default` unless your Hindsight uses namespaces." |
+| Auto-retain | on (async) | "Memories are saved in the background after each turn — no latency cost." |
+| Auto-recall | on | "Relevant memories are pulled in automatically at session start and each turn." |
+| Recall scope | `all` | "Search across everything you've done — 'have we solved this before, anywhere?'" |
+| Timeout | `4000 ms` | "Conservative: Hindsight calls never stall a turn; on timeout, recall skips and retains queue." |
+| LLM key (managed) | none (user-supplied) | "Hindsight uses your LLM key for extraction. Bobbit forwards it to the local runtime only; it never hardcodes a provider secret." |
+
+The explainer must avoid hardcoding provider-specific secrets and must frame the
+shared `bobbit` bank as the default with `hermes` as the "connect to existing"
+case — matching AJ's setup.
+
+---
+
+## 10. No-auto-start managed mode (consent)
+
+Hard invariant (preserve `startPolicy: on-enable`): **selecting managed must not
+start Docker.** The UX enforces this in three places:
+
+1. **Mode selection writes config only.** Picking a managed card in the wizard or
+ the panel `mode` select persists `mode` and shows the runtime as **Stopped**
+ (State 5). No `compose up`.
+2. **Explicit Start.** Docker starts only from the Start button (wizard step 6.3
+ or the marketplace runtime row), which is gated by the consent disclosure
+ (services/ports/volume/trust). The button label is unambiguous: "Start runtime
+ (starts Docker)".
+3. **Required-inputs gate.** Start is disabled until the mode's required inputs
+ are present (`llmApiKey` for managed; `+ externalDatabaseUrl` for
+ managed-external-postgres), with an inline "required to start" hint — matching
+ the panel's existing hints. Expected behaviour ("this will pull an image and
+ run two containers; it may take ~1–2 min the first time") is shown before the
+ first start.
+
+The existing dormancy/no-auto-start E2E coverage must be preserved; add a UI
+assertion that selecting managed mode + Save leaves `runtimeStatus: stopped` and
+issues no start call.
+
+---
+
+## 11. Discoverability — command palette & git widget
+
+- **Command palette** stays (`Hindsight Memory` launcher → opens the panel) and is
+ the secondary entry; the Marketplace row's **Configure** is the primary,
+ documented setup path. The palette item's description should read "Configure &
+ search agent memory" so it's findable by intent.
+- **Git-widget affordance.** Mirror the PR-walkthrough git-widget pattern
+ (`kind: git-widget-button`) with a **conditional** Hindsight entry that appears
+ in the git status widget dropdown **only once configured and connected**
+ (States 3/7). Selecting it opens the Hindsight panel (a `PanelTarget` launcher,
+ not a spawn — there's no sub-agent). It sits next to "PR Walkthrough" so memory
+ is reachable from the same place reviewers already look. When dormant/disabled
+ it is hidden (no dead affordance). This requires the entrypoint to support a
+ visibility predicate keyed on the pack `status` — if the entrypoint contract
+ can't gate visibility yet, render it always but route a dormant click to
+ Configure (never a broken panel).
+
+---
+
+## 12. Consistency rationale (for reviewers/coders)
+
+Per the UX consistency checklist:
+
+1. **Primitives reused.** Marketplace strip uses `market-pack-card`,
+ `market-lozenge`, `market-toggle-switch`, `market-runtime-row`, and the
+ existing consent card — no new card component. The panel keeps every `hs-*`
+ class; new rows reuse `hs-row`/`hs-chip`/`hs-badge`/`hs-btn`.
+2. **Badges match `--positive`/`--negative`/`--warning`/`--muted-foreground`** —
+ the same tokens the panel's `deriveBadge` already uses; the row reuses them so
+ the two surfaces are visually identical for the same state.
+3. **Actions sit in the same row group** as the activation toggles, not a new
+ floating bar; overflow uses the existing "More" menu pattern.
+4. **Affordances** (tooltips, disabled+busy states, confirm dialogs via
+ `confirmAction`) match the existing marketplace actions (Update/Uninstall) and
+ panel buttons (Save/Refresh/logs).
+5. **No new pattern introduced** beyond the status strip + wizard, both of which
+ compose existing primitives. The wizard reuses the dialog shell and the
+ existing input/toggle/secret field styles from the panel.
+
+Theme: tokens only, no `:root` palette, no `prefers-color-scheme`; categorical
+accents (wizard step states, progress timeline) use `--chart-1..3` with
+`color-mix` tints, exactly as the panel's chips do.
+
+---
+
+## 13. Test plan (for the testing gate — browser E2E, mocked runtime)
+
+All runtime status/log events are **mocked/stubbed**; real Docker only in
+manual-integration. Required coverage (goal):
+
+1. **First-run Configure from Marketplace** → dormant row, click Configure →
+ wizard opens → external branch → Save → row flips to Connected (stubbed
+ healthy).
+2. **Guided setup defaults/explanations** render (recommended-defaults explainer
+ present; API-vs-UI copy present; AJ example placeholders present).
+3. **External connected** state (stub healthy) and **unreachable** (stub
+ `setHealthy(false)`) render the right badge/token on both row and panel.
+4. **Stale-form refresh regression** (§7.4) — the headline test.
+5. **Open Hindsight UI** action present + opens `uiUrl` (assert anchor target,
+ not a navigation).
+6. **Managed no-auto-start** — select managed + Save → row shows Stopped, no
+ start call issued; pressing Start (with required inputs) issues exactly one
+ `start` call and the progress timeline advances through mocked status events.
+7. **Progress/status rendering** with mocked runtime events
+ (starting → running → healthy; and a failed-health path showing the failing
+ step + log tail + Retry).
+
+---
+
+## 14. Decision log
+
+- **D1 — One state function, two surfaces.** Row and panel both derive the badge
+ from `deriveHindsightState(mode, configured, healthy, runtimeStatus)` so they
+ can never disagree. The panel's current 3-way `deriveBadge` is widened to the
+ 8-state model.
+- **D2 — Marketplace is the primary setup path; palette + git-widget are
+ secondary.** Configure on the row launches the guided wizard; the wizard wraps
+ the existing `config`/`pack-runtimes` routes (no new config store).
+- **D3 — Stale-form fix is dirty-aware, not last-load-wins.** Refresh re-hydrates
+ config; clean drafts reseed; dirty drafts get a non-destructive "changed on
+ server" banner. Save re-checks freshness before clobbering a memory-backend URL.
+- **D4 — No-auto-start is enforced in the UI, not just the backend.** Mode select
+ writes config only; Start is the sole Docker trigger, gated by consent +
+ required inputs, with an explicit "starts Docker" label.
+- **D5 — API URL vs UI URL are distinct fields.** `uiUrl` is an optional,
+ non-secret config addition used solely for the "Open Hindsight UI" link; the UI
+ URL is never fabricated from the API URL.
+- **D6 — Only additive route fields.** `status.runtimeStatus?` and `config.uiUrl?`
+ are the sole (optional) contract additions; the core stale-form fix needs none.
+- **D7 — Git-widget affordance is conditional.** It appears only when
+ configured+connected and degrades to Configure rather than a dead panel when the
+ entrypoint contract can't yet gate visibility.
+
+
diff --git a/docs/design/hindsight-ux-polish.prototype.html b/docs/design/hindsight-ux-polish.prototype.html
new file mode 100644
index 000000000..08343e0f2
--- /dev/null
+++ b/docs/design/hindsight-ux-polish.prototype.html
@@ -0,0 +1,288 @@
+
+
+
+
+
+
+Hindsight UX Polish — prototype
+
+
+
+
Hindsight UX Polish
+
Interactive visual spec — Marketplace row states, actions, guided setup, and the stale-form fix. Mock data; simulated interactions. See hindsight-ux-polish.md.
+
+
1 · Marketplace installed row — state gallery
+
The built-in hindsight row gains a status strip (badge → identity → config summary → state-aware actions). One row per state below.
+
+
+
2 · Guided setup — choose a deployment
+
+
⚙︎ Set up Hindsight memory
+
+
+
+
+
+
+
3 · External branch — API URL vs UI URL
+
+
Connect existing Hindsight · step 1–2
+
+
+
+
+ Where Bobbit reads & writes memory. Usually port 9177 locally.
+ AJ example: http://localhost:9177
+ ✓ Reachable — /health 200
+
+
+
+
+ The Hindsight web UI for browsing memory. Used only for the "Open Hindsight UI" link — Bobbit never reads data through it.
+ AJ examples: http://localhost:19177/banks/hermes?view=data · http://<tailscale-host>:19177/banks/hermes?view=data
+
+
+
Shared tag-scoped bank. bobbit by default; hermes to connect to an existing one.
+
Leave default unless your Hindsight uses namespaces.
+
+
+
+
+
+
4 · Managed branch — consent & no-auto-start
+
+
Bobbit-managed · consent & start
+
+
+ Nothing starts yet. Selecting managed only saves your config. Bobbit starts Docker containers only when you press Start runtime below. First start pulls an image and may take ~1–2 min.
+
+
+
Services
api, db
Hindsight API + managed Postgres
+
Ports
127.0.0.1 (allocated on enable)
loopback only
+
Data
~/.hindsight
bind-mounted volume
+
+
→ runtime HINDSIGHT_API_LLM_API_KEY. Forwarded to the local runtime only; never hardcoded.
+
Start progress
+
+
+
+
+
+
+
5 · Recommended defaults explainer
+
+
Data locality
local / private
Memory stays on your machine unless you point at a shared deployment.
+
Bank
bobbit (shared)
One shared, tag-scoped bank. Use an existing bank like hermes only when connecting to one.
+
Auto-retain
on (async)
Saved in the background after each turn — no latency cost.
+
Auto-recall
on
Relevant memories pulled in at session start and each turn.
+
Recall scope
all
"Have we solved this before, anywhere?"
+
Timeout
4000 ms
Conservative — calls never stall a turn; on timeout recall skips, retains queue.
+
+
+
6 · Stale-form fix — dirty-aware reconcile
+
+
Clean draft → Refresh re-hydrates form
+
Form fields below reflect persisted config after Refresh (not stale defaults). Click Refresh to simulate an out-of-band config change being pulled in.
+
+
+
+
+
+
+
Configuration changed on the server since you started editing.
+
+
+
+
+
+
diff --git a/docs/extension-host-authoring.md b/docs/extension-host-authoring.md
index ef7aaaa4d..7c0966d6d 100644
--- a/docs/extension-host-authoring.md
+++ b/docs/extension-host-authoring.md
@@ -43,6 +43,7 @@ The renderer+action working example lives at `tests/fixtures/market-sources/retr
| **Entrypoints** | `entrypoints/.yaml` (listed in `contents`) | Browser (launchers + deep-link routes) | `host.ui.navigate` / `openPanel` |
| **Pack store** | *implicit* — no declaration | Gateway | `host.store.{get,put,list,delete,deletePrefix,stats}` (pack-namespaced) |
| **Providers** *(schema 2; all hooks wired via the Lifecycle Hub)* | `providers/.yaml` (listed in `contents.providers`) | Server (Lifecycle Hub, worker tier) | default-export hook object — see [docs/lifecycle-hub.md](lifecycle-hub.md) |
+
Plus the cross-cutting `host.session.*` (transcript reads, agent-driving posts, live events)
and the server-side `host.agents.*` (launch + orchestrate child agents), available to surfaces
that hold a `host`.
@@ -90,6 +91,7 @@ A pack is a directory with a `pack.yaml` plus an entity payload. The full V1 lay
panels/.yaml # pack-scoped panel definitions, one file each (auto-discovered)
entrypoints/.yaml # pack-scoped launcher/deep-link definitions, one file each
providers/.yaml # schema-2 provider contributions (listed in contents.providers; dispatched via the Lifecycle Hub)
+
lib/ # shared implementation modules, NOT entities
SharedRenderer.js
ArtifactViewerPanel.js
@@ -798,7 +800,7 @@ a fresh read-only reviewer sub-agent and the panel lives only in that child sess
packs declaring the same `routeId` is a hard rejection at registry build). Panel ids referenced
by `target.panelId` are pack-local.
-### Providers (`providers/.yaml`) — schema 2; `sessionSetup` wired into sessions
+### Providers (`providers/.yaml`) — schema 2; lifecycle hooks wired
**Status:** a `schema: 2` pack may ship **provider** contributions — a pack-scoped
contribution loaded into the same `PackContributionRegistry` as panels/entrypoints/routes.
@@ -810,18 +812,24 @@ worker tier with a per-provider timeout, collects the returned `ContextBlock`s,
budgets, fences the content, and records a trace. See [docs/lifecycle-hub.md](lifecycle-hub.md)
for the full Hub contract.
-**The `sessionSetup` hook is now wired into the session runtime.** When a new session is
-created, the Hub dispatches `sessionSetup` and the returned blocks render as a final
-**Dynamic Context** prompt section (visible in the prompt-sections inspector with
-`source: "providers"` provenance) — so a provider that declares `sessionSetup` and is installed +
-active + enabled for the session's scope contributes context today. A provider fault never blocks
-the spawn. **All five hooks are now wired** (G1.3 + G1.4): the per-turn `beforePrompt` /
-`beforeCompact` fire via a generated provider-bridge pi extension, and `afterTurn` /
-`sessionShutdown` fire server-side from the gateway's agent-event stream. The first built-in
-production provider — the [Hindsight memory pack](hindsight-memory.md) — now ships in the built-in
-band, but it is **dormant until a Hindsight URL is configured**, so an out-of-the-box install
-still produces no Dynamic Context section. See
-[docs/lifecycle-hub.md → Session-setup wiring](lifecycle-hub.md#session-setup-wiring-g13)and [Per-turn + lifecycle wiring](lifecycle-hub.md#per-turn--lifecycle-wiring-g14).
+**Provider lifecycle hooks are wired.** When a new session is created, the Hub dispatches
+`sessionSetup` and the returned blocks render as a final **Dynamic Context** prompt section
+(visible in the prompt-sections inspector with `source: "providers"` provenance) — so a provider
+that declares `sessionSetup` and is installed + active + enabled for the session's scope contributes
+context today. A provider fault never blocks the spawn. Per-turn `beforePrompt` / `beforeCompact`
+fire via a generated provider-bridge pi extension, `afterTurn` / `sessionShutdown` fire server-side
+from the gateway's agent-event stream, and `goalCompleted` fires after successful goal completion
+for non-blocking outcome/cleanup side effects.
+
+`beforePrompt` blocks are delivered as hidden `bobbit:dynamic-context` custom/user-side messages,
+not appended to `systemPrompt`, so provider cached system-prompt bytes stay stable across turns;
+`sessionSetup` blocks remain spawn-time system-prompt context and `beforeCompact` is unchanged.
+
+The first built-in production provider — the [Hindsight memory pack](hindsight-memory.md) — ships
+with `defaultDisabled: true`, so a fresh unconfigured install contributes no tools, provider hooks,
+entrypoints, runtime, or Dynamic Context until setup enables/configures it; existing configured
+installs remain active. See [docs/lifecycle-hub.md → Session-setup wiring](lifecycle-hub.md#session-setup-wiring-g13)
+and [Per-turn + lifecycle wiring](lifecycle-hub.md#per-turn--lifecycle-wiring-g14).
Unlike every other contribution in this guide, a provider has **no `ctx.host` Host-API
surface** — it is not reached through the panel/entrypoint/route Host API. Instead, when the Hub
@@ -840,10 +848,12 @@ Key author-facing rules (full reference, field table, defaults, and clamps live
- `module` resolves relative to the provider YAML and is containment-checked against the pack
root — the same guard as routes/entrypoints.
- `hooks` must be a subset of `sessionSetup` / `beforePrompt` / `afterTurn` / `beforeCompact` /
- `sessionShutdown` / `goalProvisioned`; an unknown hook drops *that* provider (warn) and the rest
- of the pack still loads. `goalProvisioned` is a fire-and-forget **filesystem-treatment** hook
- (returns no context blocks) dispatched at every worktree provisioning in a goal's subtree with
- the goal's resolved metadata; it must be cheap and idempotent. See
+ `sessionShutdown` / `goalProvisioned` / `goalCompleted`; an unknown hook drops *that* provider
+ (warn) and the rest of the pack still loads. `goalProvisioned` is a fire-and-forget
+ **filesystem-treatment** hook (returns no context blocks) dispatched at every worktree
+ provisioning in a goal's subtree with the goal's resolved metadata; it must be cheap and
+ idempotent. `goalCompleted` fires after successful goal/team completion for non-blocking outcome
+ side effects and cannot roll back completion. See
[Hierarchical goal metadata](design/goal-metadata.md#6-extension-goal-lifecycle-hook) and
[lifecycle-hub.md](lifecycle-hub.md).
- `budget` (`{ maxTokens, timeoutMs }`) bounds dispatch: `maxTokens` is clamped to `[64, 8192]`
@@ -855,8 +865,8 @@ Key author-facing rules (full reference, field table, defaults, and clamps live
A provider `module` is authored as a **default-export object** whose members are the hook
handlers — **not** a named `providers` export (this is what distinguishes the provider worker
-path from routes/actions). Each handler is `async (ctx) => ({ blocks: [...] })` and returns
-`ContextBlock`s (a bare `ContextBlock[]` is also accepted):
+path from routes/actions). Context-producing hooks return `async (ctx) => ({ blocks: [...] })`
+(or a bare `ContextBlock[]`); side-effect-only hooks such as `goalCompleted` may return nothing:
```js
// providers/memory.mjs
@@ -877,6 +887,9 @@ export default {
};
},
async beforePrompt(ctx) { /* … */ },
+ async goalCompleted(ctx) {
+ // Side effects only: retain outcome summaries, cleanup, etc. Returned blocks are ignored.
+ },
};
```
diff --git a/docs/features.md b/docs/features.md
index 6a42baa1c..a02f2797b 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -106,6 +106,17 @@ Workflows define the gates a goal must pass, their dependency relationships (a D
The PR walkthrough panel is a guided pull-request or changeset review surface. It ships as a **built-in first-party pack** (`market-packs/pr-walkthrough/`) that is auto-resolved active-by-default — there is no manual install. The pack owns the viewer surfaces and the reviewer tools under `tools/pr-walkthrough/`; `pack.yaml` advertises the `pr-walkthrough` tool group, and Market expands it into concrete tool toggles. Two pack launchers (composer-slash / session menu) do the **same** thing on click: they call the pack's `run` route, which mints a **separate, isolated, read-only reviewer child** (`host.agents.spawn`, role `pr-reviewer`, `title: "PR Walkthrough"`) — it never drives the user's current agent — and then **auto-switch the view to that child session**, opening the panel there. There is **no owner-session panel** and **no manual "Run PR walkthrough" / "Load walkthrough" buttons**. A no-PR / spawn failure surfaces through visible launcher feedback from the session menu, spawning nothing and not switching the view; every click is a fresh reviewer (no dedup). The reviewer publishes cards only through validated `submit_pr_walkthrough_yaml`, and on submit it is **not** dismissed — it stays live and selectable until the user terminates it. The run path is GitHub-PR-only. Disabling the pack from the Market built-in section makes the feature unavailable (the deep-link degrades to an empty state). See [pr-walkthrough-panel.md](pr-walkthrough-panel.md) for the full behaviour and testing contract, [pr-walkthrough-launch-ux.md](design/pr-walkthrough-launch-ux.md) for the launch model, and [built-in-first-party-packs.md](design/built-in-first-party-packs.md) for the pack model.
+## Hindsight Memory
+
+The **Hindsight** built-in first-party pack (`market-packs/hindsight/`) gives agents persistent,
+cross-session memory backed by a Hindsight instance: it recalls relevant past memories into the
+prompt and retains a compact summary of each turn. It ships with `defaultDisabled: true`, so fresh
+installs have no Hindsight tools, provider hooks, entrypoints, runtime, network calls, or prompt
+drift until the operator enables/configures it through Marketplace setup. Existing configured
+installations remain active.
+
+All configuration, setup, and re-configuration are performed directly inside the **Marketplace** (via the inline Configure form/wizard on the Marketplace page). The session actions overflow menu entry (**Hindsight Memory**) and deep link `#/ext/hindsight` now open the **live Hindsight dashboard embedded as a sandboxed iframe** (utilizing `uiUrl`) in a first-class in-app Bobbit tab/panel. This lets the user seamlessly use, view, and query the memory bank without leaving the application. When `uiUrl` is unset, the in-app tab directs the user to the Marketplace for configuration with a helpful Call-to-Action (CTA) and displays any available API/external status context. See [hindsight-memory.md](hindsight-memory.md) for the full behaviour and [managed-runtimes.md](managed-runtimes.md#p3--deployment-modes-consent--lifecycle) for the managed Docker/Postgres runtime details.
+
## Assistant Registry
A unified registry (`assistant-registry.ts`) maps assistant types to their prompts and display titles. Builtin definitions ship in `defaults/roles/assistant/` (user overrides in `.bobbit/config/roles/assistant/`), falling back to hardcoded defaults:
@@ -126,9 +137,9 @@ Context compaction reduces token usage by summarising the conversation.
## System Prompt Assembly
-Each session's system prompt is assembled from a fixed set of ordered sections (numbered below) plus an optional provider-supplied tail. Sections are separated by `\n\n---\n\n` and written to `.bobbit/state/session-prompts/{sessionId}.md` at spawn time.
+Each session's system prompt is assembled from a fixed set of ordered sections (numbered below), including optional spawn-time provider context from `sessionSetup`. Sections are separated by `\n\n---\n\n` and written to `.bobbit/state/session-prompts/{sessionId}.md` at spawn time. Per-turn `beforePrompt` Dynamic Context is delivered separately as hidden `bobbit:dynamic-context` custom/user-side messages, not as a system-prompt tail.
-The sections are ordered so that the **stable prefix** (sections 1–5, which are deterministic functions of the project and allowed tools) comes before the **volatile suffix** (sections 6–8, which vary per goal/task/session). This ordering lets provider prompt caches (Anthropic ephemeral, OpenAI prompt cache) reuse the tool docs and skills catalog across team spawns and between turns, because the cache key only invalidates at the first changed byte.
+The sections are ordered so that the **stable prefix** (sections 1–5, which are deterministic functions of the project and allowed tools) comes before the **volatile suffix** (sections 6–9, which vary per goal/task/session). This ordering lets provider prompt caches (Anthropic ephemeral, OpenAI prompt cache) reuse the tool docs and skills catalog across team spawns and between turns, because the cache key only invalidates at the first changed byte.
| # | Section | Volatile? | Source |
|---|---------|-----------|--------|
@@ -142,7 +153,7 @@ The sections are ordered so that the **stable prefix** (sections 1–5, which ar
| 8 | **Workflow upstream-gate context** | Yes | Passed gate content injected for context. Omitted when not in a workflow. |
| 9 | **Dynamic Context** | Yes | Provider-supplied ambient context from the `sessionSetup` lifecycle hook, fenced in `` envelopes. Appended last (freshest, lowest-authority). Omitted unless an active provider contributes blocks. See [lifecycle-hub.md](lifecycle-hub.md#session-setup-wiring-g13). |
-Implementation: `src/server/agent/system-prompt.ts::_assembleSystemPrompt`. The inspector UI uses `getPromptSections()` (same file) to show labeled sections in the same order. Section 9 is appended after section 8 by the `sessionSetup` provider wiring (Extension Platform G1.3); when no provider contributes, it is absent and the prompt is byte-identical to the 1–8 layout.
+Implementation: `src/server/agent/system-prompt.ts::_assembleSystemPrompt`. The inspector UI uses `getPromptSections()` (same file) to show labeled sections in the same order. Section 9 is appended after section 8 by the `sessionSetup` provider wiring (Extension Platform G1.3); when no provider contributes, it is absent and the prompt is byte-identical to the 1–8 layout. The same inspector section is refreshed best-effort for per-turn `beforePrompt` blocks, but those blocks reach the model through the hidden custom-message channel so provider cached system-prompt bytes stay stable across turns.
## Reconnection
diff --git a/docs/hindsight-integration-brief.md b/docs/hindsight-integration-brief.md
new file mode 100644
index 000000000..4beb7ad73
--- /dev/null
+++ b/docs/hindsight-integration-brief.md
@@ -0,0 +1,295 @@
+# Hindsight memory v2 implementation brief
+
+This document records the final Hindsight memory v2 implementation state. It supersedes the earlier design-only brief that was copied from `/tmp/hindsight-integration-brief.md`; the implementation has been reconciled against `market-packs/hindsight/`, current tests, and Hindsight 0.8.3 API shapes observed at `http://127.0.0.1:9177`.
+
+Scope is pure memory mechanics. UI and surface work is documented separately.
+
+## Executive summary
+
+Hindsight memory v2 extends the existing built-in `hindsight` pack rather than adding a second memory path. The pack now:
+
+- injects a per-project mental model at `sessionSetup` when available, and falls back to the previous raw session-start recall when it is absent, pending, skipped, or failed;
+- keeps recall observation-biased, sends `query_timestamp` by default, and preserves the Hindsight 0.8.3 chunks-off default by not sending `include.chunks` from normal recall paths;
+- forwards structured reflect options (`response_schema`, `fact_types`, budgets, `exclude_mental_models`) and surfaces `structuredOutput` through the route/tool;
+- emits a replaceable goal/PR outcome digest through a `goalCompleted` lifecycle hook, with a stable `document_id`, `update_mode: "replace"`, task/gate/PR summaries, entities, metadata, and project observation scopes;
+- forwards advanced retain fields (`document_id`, `update_mode`, `entities`, `timestamp`, `observation_scopes`, `metadata`) and preserves them through the retry queue;
+- keeps bank-wide directive writes disabled by default for shared banks, with explicit opt-in support and a per-request reflect instruction fallback;
+- health-gates and bounds retain queue drains so recovery does not burst into an unhealthy daemon;
+- adds reversible invalidation through a minimal route/tool that patches memory state instead of deleting data.
+
+## Verified Hindsight 0.8.3 API mapping
+
+Bobbit's REST client targets `/v1/{namespace}/banks/{bank}/...`, defaulting to namespace `default`. Current implementation maps these Hindsight 0.8.3 fields:
+
+### Recall: `POST /memories/recall`
+
+Request fields used by Bobbit:
+
+- `query`
+- `max_tokens` from `recallBudget`
+- `types` from `recallTypes`, default `['observation', 'world', 'experience']`
+- `tags` and `tags_match` from the shared `recallTagFilter()` helper
+- `query_timestamp` when `recallQueryTimestampEnabled` is true or the route receives an explicit timestamp
+- optional `budget`, `trace`, and `include` only when a direct caller explicitly passes them to the client
+
+Normal provider and route recall intentionally omit `include`. In Hindsight 0.8.3, `include.chunks` is disabled by default and chunks are only requested by setting `include.chunks` to `{}`. The v2 tests pin that ordinary recall bodies contain no default `include.chunks`.
+
+### Reflect: `POST /reflect`
+
+Bobbit forwards:
+
+- `query`
+- `tags` / `tags_match`
+- `response_schema` from route/tool `responseSchema`
+- `fact_types` from route/tool `factTypes`
+- `budget`, `max_tokens`, `exclude_mental_models`, and `exclude_mental_model_ids` when supplied
+
+The client returns `{ text, structuredOutput? }`, mapping Hindsight `structured_output` to camel case. The tool displays `structuredOutput` as JSON and also exposes it under `details.structuredOutput`.
+
+### Retain: `POST /memories`
+
+Bobbit writes item-level fields:
+
+- `content`
+- `tags`
+- `document_id`
+- `update_mode`
+- `entities`
+- `timestamp`
+- `observation_scopes`
+- `metadata`
+
+Request-level `async` is `!sync`: routine and outcome retains are async; compaction safety retains are synchronous. Event time is `timestamp`, not `event_date`. Project observation scope is nested as `[["project:"]]`, not a flat tag list.
+
+### Mental models
+
+Bobbit uses:
+
+- `GET /mental-models/{id}`
+- `POST /mental-models`
+- `POST /mental-models/{id}/refresh`
+- supporting list/update/clear/history client methods for parity and tests
+
+Create uses a stable id `bobbit-`, tags `project:`, `bobbit`, and `kind:mental-model`, and a trigger that prefers the configured fact types, excludes mental models, applies strict project tags, and does not request chunks. Creation is async, so a newly created model can be `empty` until Hindsight finishes the operation.
+
+### Directives, health, operations, curation
+
+- Directives are first-class `/directives` resources. Bobbit treats them as potentially bank-wide and keeps writes disabled unless explicitly opted in.
+- Queue drain health uses `GET /health`; optional LLM gating uses `POST /health/llm`.
+- Operations support is exposed through client methods for diagnostics/retry workflows.
+- Reversible invalidation uses `PATCH /memories/{id}` with `{ state: "invalidated", reason }`. The agent tool does not expose destructive delete.
+
+## Current pack behavior by feature
+
+### 1. Per-project mental model at `sessionSetup`
+
+`sessionSetup` first tries `doMentalModel()` when `autoRecall` and `mentalModelEnabled` are true and the session has a project id.
+
+States:
+
+| State | Meaning | `sessionSetup` behavior |
+|---|---|---|
+| `injected` | Hindsight returned non-empty model content or a usable `reflect_response.text`. | Inject one `Project memory model` context block (`authority: memory`, priority above raw recall) and make zero raw recall calls for that hook. |
+| `empty` | The model is absent, newly created, or pending async generation. | Fall back to normal session-start recall. |
+| `skipped` | Auto recall/model config is off, there is no project id, or the client lacks mental-model support. | Fall back to normal session-start recall. |
+| `failed` | Mental-model API failed. | Record diagnostics and fall back to normal session-start recall. |
+
+Refresh is opportunistic. If the model is stale or the `mentalModelRefreshEveryMs` cadence has elapsed, Bobbit calls `refreshMentalModel()` best-effort and does not wait for Hindsight's async operation to finish. Fresh model content is still injected even if refresh fails.
+
+Why: one curated project-state block avoids broad session-start recall when prior work exists, while preserving cold-start behavior when the model is not ready.
+
+### 2. Observation-biased recall, timestamp anchoring, chunks-off guard
+
+Recall remains tag-scoped to the shared bank:
+
+- `project` scope adds the current project tag and uses `tagsMatch` (default `any`, meaning project + global/untagged memories while excluding other projects).
+- Extra tags narrow project-scope reads with strict all-tags semantics.
+- `all` scope searches the configured bank without a fabricated project tag.
+
+Recall quality and token controls:
+
+- `recallTypes` defaults to `['observation', 'world', 'experience']`, biasing toward consolidated observations while preserving useful facts and experiences.
+- `recallQueryTimestampEnabled` defaults to true and sends the current ISO `query_timestamp` from provider recall. The route accepts an explicit `queryTimestamp` override or `false`/`null` to omit it.
+- Normal provider/route recall sends no `include`, so `include.chunks` stays disabled under Hindsight 0.8.3 defaults.
+- `clampRecallQuery()` enforces a token-safe character ceiling before the API call; a defensive 400 "Query too long" classifier soft-skips recall and clears sticky errors.
+
+### 3. Structured reflect
+
+The `reflect` route and `hindsight_reflect` tool accept:
+
+- `responseSchema` → `response_schema`
+- `factTypes` → `fact_types`
+- `excludeMentalModels` → `exclude_mental_models`
+- `budget` and `maxTokens`
+
+The route returns `{ configured, text, structuredOutput? }`. If Hindsight rejects a schema or returns no structured output, Bobbit falls back to the text response.
+
+When bank directives are disabled, the route prepends Bobbit's per-request coding-agent reflection instruction. This keeps agent-facing behavior consistent without mutating a shared Hindsight bank.
+
+### 4. `goalCompleted` outcome digests
+
+The server dispatches a non-fatal `goalCompleted` lifecycle hook after team completion succeeds. Dispatch is collapsed in memory for concurrent completions and guarded by a persisted marker keyed like `goalCompleted::` under the goal state directory. If no active provider declares `goalCompleted`, no marker is burned.
+
+The completion context includes best-effort project and workflow details:
+
+- goal id, project id, branch, merge target, root/parent goal ids, worktree/cwd, team lead session id, completion timestamp, and head SHA;
+- cached PR number/state/url/head SHA when available;
+- gate summaries (`gateId`, name, status, signal count, latest commit SHA, metadata/content);
+- task summaries (`id`, title, type, state, branch, head SHA, result summary);
+- touched files from task summaries and Git name-only probes.
+
+The Hindsight provider retains one async outcome digest with:
+
+- `document_id: "outcome:"`
+- `update_mode: "replace"`
+- `tags` including `kind:outcome`, `bobbit:true`, project, goal, and PR when known
+- file/component entities when known
+- `timestamp` from completion time
+- project `observation_scopes` as `[["project:"]]`
+- metadata such as `headSha`, branch, merge target, and PR URL
+
+Provider failures never fail goal completion. If retain fails and a store is available, the digest is queued with all advanced fields preserved so replay writes the same replaceable document. The `hindsight_retain_outcome` route/tool remains a manual repair path and uses the same stable document id and replace semantics for goal or PR digests.
+
+### 5. Advanced retain fields, entities, and observation scopes
+
+The client and provider now forward advanced retain metadata. Automatic turn and compaction retains include:
+
+- project observation scopes when a project id exists;
+- file/component entities only when the hook context provides them;
+- an item-level `timestamp`;
+- existing auto-tags for kind/project/goal/agent/session.
+
+The durable retry queue stores the original bank, namespace, tags, document id, update mode, entities, timestamp, observation scopes, and metadata. Replay uses the original target bank/namespace, not the next hook's effective config, so queued writes cannot drift across project overrides.
+
+Manual `hindsight_retain` stays conservative: it writes content plus manual/project tags and does not expose the full metadata surface. Outcome metadata uses the dedicated `retain_outcome` route/tool.
+
+### 6. Directive behavior
+
+Directive writes are deliberately off by default:
+
+- `directivesEnabled: false`
+- `directiveApplyMode: "disabled"`
+
+Why: the common `hermes` bank is shared, and Hindsight 0.8.3 does not prove that directive tags safely scope directive application per request. Bobbit therefore avoids installing active bank-wide coding-agent directives unless an operator explicitly opts in.
+
+When enabled, Bobbit:
+
+- lists existing directives;
+- creates or updates only stable `bobbit-` prefixed directives;
+- attaches the `bobbit` tag;
+- caches a signature per namespace/bank so it does not rewrite identical directives every hook;
+- treats failures as diagnostics only.
+
+When disabled, `reflect` still gets Bobbit-specific behavior through a per-request instruction prefix. Callers can suppress that prefix with `bobbitInstruction: false` in the route body.
+
+### 7. Health-gated bounded retain queue
+
+Failed retain calls enter a durable FIFO queue capped at 100 entries. Queue drains are now gated and bounded:
+
+- `retainQueueHealthGate` defaults true and requires `client.health().ok !== false` before any drain.
+- `retainQueueLlmHealthGate` defaults false; when enabled, `/health/llm` must not report failed operation health.
+- `retainQueueDrainMaxPerHook` defaults 1 for `afterTurn` drain.
+- `retainQueueShutdownMax` defaults 10, replacing unbounded shutdown bursts.
+- `retainQueueBatchPauseMs` is reserved for future paced drains; normal hooks avoid sleeps.
+
+If health probing fails, the queue is left untouched and a non-fatal diagnostic is recorded. Direct active-turn retain attempts can still run and enqueue on failure.
+
+### 8. Reversible invalidation
+
+The `invalidate` route and `hindsight_invalidate` tool require a memory `id` and non-empty `reason`. They call `client.invalidateMemory(bank, id, reason)`, which sends:
+
+```json
+{ "state": "invalidated", "reason": "..." }
+```
+
+This retires stale or incorrect memories without deleting history. Revert/history workflows remain direct Hindsight API operations.
+
+## Configuration keys and operational defaults
+
+The source of truth is `market-packs/hindsight/providers/memory.yaml`, mirrored by `CONFIG_DEFAULTS` in `market-packs/hindsight/src/shared.ts`.
+
+| Key | Default | Notes |
+|---|---:|---|
+| `recallTypes` | `['observation','world','experience']` | Observation-biased recall. |
+| `recallQueryTimestampEnabled` | `true` | Send current ISO `query_timestamp` unless disabled or explicitly omitted by route body. |
+| `mentalModelEnabled` | `true` | Try mental-model injection at `sessionSetup`. |
+| `mentalModelMaxTokens` | `1000` | Model content budget. |
+| `mentalModelRefreshEveryMs` | `86400000` | Best-effort refresh cadence; `0` disables cadence-based refresh in the provider implementation. |
+| `mentalModelRecallMaxTokens` | `1200` | Trigger recall budget for model generation support. |
+| `directivesEnabled` | `false` | No bank-wide directive writes by default. |
+| `directiveApplyMode` | `disabled` | Explicit opt-in required for bank directive writes. |
+| `directiveSetVersion` | `bobbit-v1` | Part of the idempotency signature. |
+| `directives` | Bobbit coding-agent directive | Written only when directive application is enabled. |
+| `retainQueueDrainMaxPerHook` | `1` | Max queued entries retried during `afterTurn`. |
+| `retainQueueShutdownMax` | `10` | Max queued entries retried during `sessionShutdown`. |
+| `retainQueueHealthGate` | `true` | Gate queue drains on `/health`. |
+| `retainQueueLlmHealthGate` | `false` | Optional `/health/llm` gate. |
+| `retainQueueBatchPauseMs` | `0` | Reserved pacing knob; no sleep by default. |
+
+Existing defaults remain important: the pack manifest declares `defaultDisabled: true` so fresh unconfigured installs have no Hindsight contributions until Marketplace setup enables/configures them; external mode still requires `externalUrl` once enabled. Existing configured installs preserve activation. `retainEveryNTurns` remains 5, `retainMaxDelayMs` remains 30 minutes, `retainOverlapTurns` remains 2, `recallBudget` remains 1200, and `timeoutMs` defaults to 4000 ms.
+
+Per-project overrides are still limited to memory-quality keys (`recallScope`, `bank`, `tagsMatch`, `recallBudget`, `recallTypes`). Deployment mode, URLs, and secrets remain server-global.
+
+## Before/after token and call cost model
+
+Baseline before v2:
+
+- `sessionSetup` could make one broad recall and inject up to `recallBudget` tokens, default 1200.
+- `beforePrompt` made targeted recall with the same budget.
+- Recall was already observation-biased and chunk-free by default.
+- Routine retain was already batched: one extraction per `retainEveryNTurns` turns, default 5.
+- Shutdown queue drain could attempt every queued item.
+
+After v2:
+
+- When a mental model is available, `sessionSetup` does one model get and injects a curated `Project memory model` block capped by `mentalModelMaxTokens` (default 1000), and does zero raw recall calls for that hook.
+- When the model is missing, pending, skipped, or failed, `sessionSetup` falls back to the old raw recall path.
+- Per-turn `beforePrompt` recall remains targeted, observation-biased, timestamp-anchored, and chunk-free.
+- Routine retain frequency remains one call per 5 turns at defaults.
+- Goal completion adds one async replace retain per completed goal/head. Re-emits replace the existing digest rather than adding duplicates.
+- Queue recovery is bounded by `retainQueueDrainMaxPerHook`/`retainQueueShutdownMax` and gated on health.
+- Structured reflect can return a compact JSON object, reducing prose parsing and follow-up turns for schema-driven memory queries.
+
+Expected direct prompt-token win: session-start memory drops from up to 1200 raw recall tokens to a curated ~1000-token mental model when available, roughly 15-25% if the model stays near 900-1000 tokens. The larger expected win is fewer rediscovery turns because completed outcomes and project state are retained in stable, recallable forms.
+
+## Validation commands
+
+Use these after changing Hindsight memory mechanics:
+
+```bash
+npm run check
+npx tsx --import ./tests/helpers/css-stub-loader.mjs --test --test-force-exit tests/hindsight-client.test.ts tests/hindsight-provider.test.ts tests/hindsight-tool-execute.test.ts tests/lifecycle-hub.test.ts tests/team-manager.test.ts
+npm run test:e2e:run -- tests/e2e/hindsight-agent-tools.spec.ts tests/e2e/hindsight-external.spec.ts
+```
+
+If the test runner does not support filtering in your shell, run the full phases instead:
+
+```bash
+npm run test:unit
+npm run test:e2e
+```
+
+Manual real-daemon validation remains optional and should use the manual integration target only when a local Hindsight is intentionally running:
+
+```bash
+HINDSIGHT_URL=http://localhost:9177 HINDSIGHT_BANK=bobbit-it npm run test:manual -- --grep hindsight
+```
+
+Audit points to confirm in test output or stub captured calls:
+
+- model-injected `sessionSetup` has zero raw recall calls;
+- recall bodies include `types` and `query_timestamp`, and omit `include.chunks` by default;
+- structured reflect returns `structuredOutput` when `responseSchema` is supplied;
+- outcome retains use `document_id: outcome:`, `update_mode: replace`, project outcome tags, entities, metadata, and nested observation scopes;
+- health-gated drains skip when `/health` is unhealthy and drain no more than configured limits;
+- invalidated memories are excluded from later recall by the stub.
+
+## Operator recommendations
+
+Do not change operator-owned daemon configuration from Bobbit. Recommend only:
+
+- keep the local reranker enabled when available;
+- consider `HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY=0.2-0.3` to reduce weak semantic matches;
+- consider recall strategy boosts for entity/graph and recency signals;
+- keep dashboard/server chunk availability if useful for humans, but rely on Bobbit's per-request behavior to keep agent recall chunk-free;
+- apply launchd or daemon environment changes only in the operator's Hindsight service configuration, never from the pack.
diff --git a/docs/hindsight-memory.md b/docs/hindsight-memory.md
index 16356f91a..ed73d08a2 100644
--- a/docs/hindsight-memory.md
+++ b/docs/hindsight-memory.md
@@ -13,44 +13,49 @@ in [docs/design/hindsight-pack-external.md](design/hindsight-pack-external.md),
covers the topology rationale (one shared bank, tag-scoped) summarised under
[Bank & tag taxonomy](#bank--tag-taxonomy) below.
-> **Scope of this release (Extension Platform G2.1 + G2.2).** Only **external mode** ships — you
-> point the pack at a Hindsight URL you already run. The managed Docker/Postgres runtime, the
-> explicit `hindsight_recall/retain/reflect` agent tools, the native memory panel, the reflect UI,
-> and cross-engine dedupe are **out of scope** here — see [Non-goals](#non-goals).
-
-## Installed but dormant by default
-
-The pack is in the built-in band, so it is **present and active by default** on a fresh install —
-but it does **nothing** until a Hindsight URL is configured. This is a hard, tested guarantee, not
-a soft default:
-
-- The provider declares `activation.requiresConfig: [externalUrl]` in
- `providers/memory.yaml`. The host omits the provider entirely from
- `listProviders(projectId)` until the effective config has a **non-empty `externalUrl`**.
-- Consequently, on an unconfigured install there is **no active provider**: no provider-bridge
- pi extension is spawned, no per-turn `/provider-hooks/*` calls are made, the assembled
- system-prompt text is **byte-identical** to a no-pack baseline, and **no Hindsight network is
- touched**.
-- The provider also re-checks the same gate defensively at runtime (`isActive(cfg)` in
- `market-packs/hindsight/src/shared.ts`): unless `mode === "external"` **and** `externalUrl` is a
- non-empty string, every hook returns immediately (`{ blocks: [] }` for recall hooks, a no-op for
- retain hooks) and constructs no client.
-
-**Why dormant-by-default?** Memory is only useful if a backing store exists, and Bobbit must never
-make outbound calls or change prompts for users who have not opted in. Shipping the pack dormant
-means the feature is one config field away without imposing any cost — latency, network, or prompt
-drift — on everyone else.
-
-Like any first-party pack, you can also fully **disable** it from the Market UI; disabling is the
-only opt-out (there is no uninstall for built-in packs). See
+> **Scope.** This page documents the **external mode** data plane — you point the pack at a
+> Hindsight URL you already run. The **managed Docker/Postgres runtime** (deployment modes
+> `managed` and `managed-external-postgres`, explicit-consent start, disable/uninstall/purge, and
+> `ctx.runtime` injection) now ships as **P3** and is documented in
+> [managed-runtimes.md — P3](managed-runtimes.md#p3--deployment-modes-consent--lifecycle). The
+> explicit `hindsight_recall/retain/reflect/retain_outcome/invalidate` agent tools ship as **P5** — see
+> [Agent tools](#agent-tools). The current **setup and dashboard UX** (Marketplace configuration,
+> guided setup walkthrough, the stale-form fix, `uiUrl`, and embedded dashboard entrypoints) ships
+> as the **UX polish** pass — see [Setup UX](#setup-ux--marketplace-front-door-state-model--guided-setup),
+> [Embedded Dashboard Tab](#embedded-dashboard-tab), and the design spec
+> [docs/design/hindsight-ux-polish.md](design/hindsight-ux-polish.md). The reflect UI and
+> cross-engine dedupe remain **out of scope** — see [Non-goals](#non-goals).
+
+## Installed but disabled by default
+
+The pack ships in the built-in band with `defaultDisabled: true` in `pack.yaml`. On a fresh,
+unconfigured install Bobbit synthesizes an activation overlay that disables all Hindsight
+contributions — tools, provider, entrypoints, and managed runtime — until an operator explicitly
+enables/configures it through Marketplace setup.
+
+Activation is preserved for existing live setups: if persisted Hindsight config already has a
+non-empty `externalUrl` or selects a managed mode, the default-disabled overlay is not applied. Once
+the pack is enabled, the provider still uses its own activation gates: external mode requires
+`externalUrl`, managed modes require a running host-injected runtime, and inactive hooks construct
+no client.
+
+**Why disabled-by-default?** Memory is only useful if a backing store exists, and Bobbit must never
+make outbound calls, add tools, show entrypoints, start a runtime, or change prompts for users who
+have not opted in. Disabling the pack again from Market returns it to the same no-tools/no-provider
+state; there is no uninstall for built-in packs. See
[built-in first-party packs](marketplace.md#built-in-first-party-packs).
## Turning it on
-Set the provider config (via the `config` pack route — see [Pack routes](#pack-routes)) with at
-least `externalUrl` pointing at your Hindsight base URL (default Hindsight port is `8888`). Once
-the effective config has a non-empty URL, the provider activates on the next session spawn and
-starts recalling and retaining.
+The **Marketplace** is the single, authoritative home for configuring Hindsight. Setting up or reconfiguring the memory pack (deployment mode, URLs, bank, scope, and toggles) happens strictly through the inline **Configure** form or the guided wizard in the Marketplace's `hindsight` pack row.
+
+The configuration form is simple: set the deployment mode and the required URLs (such as `externalUrl` for external mode) and Save. Under the hood, the Marketplace configuration writes through the `config` pack route (see [Pack routes](#pack-routes)), so it can also be driven programmatically. Once the effective configuration is valid, the provider activates on the next session spawn to start automatically recalling and retaining.
+
+In contrast, the session-menu entry (**Hindsight Memory**) and the `#/ext/hindsight` deep link (route) are used for **using, viewing, and querying** memory within the app (see [Embedded Dashboard Tab](#embedded-dashboard-tab)), not for configuration.
+
+> Earlier the only non-test way to configure the pack was seeding the pack store directly. With P4
+> the panel + `config` route are the user-facing path; **store-seeding is now a test-only seam**
+> (used by the E2E `seedConfig` helper), not a documented configuration mechanism.
### Configuration keys
@@ -61,20 +66,65 @@ provider reads.
| Key | Type | Default | Meaning |
|---|---|---|---|
-| `mode` | enum `external` \| `managed` | `external` | Deployment mode. **`managed` is reserved for G3** and does nothing here; only `external` activates the provider. |
-| `externalUrl` | string (optional) | — | Base URL of your running Hindsight. **Empty ⇒ dormant.** This is the single field that switches the pack on. |
-| `apiKey` | secret (optional) | — | Bearer token. Sent as `Authorization: Bearer ` **only when set**; never echoed back (the `config` GET surface collapses it to a boolean `apiKeySet`). |
+| `mode` | enum `external` \| `managed` \| `managed-external-postgres` | `external` | Deployment mode. `external` is documented here; the two managed modes start a Docker runtime — see [managed-runtimes.md — P3](managed-runtimes.md#p3--deployment-modes-consent--lifecycle). Only `external` activates via `externalUrl`; managed modes activate via `activeWhenConfig`. |
+| `externalUrl` | string (optional) | — | Base URL of your running Hindsight **data-plane API** (external mode) — where Bobbit reads and writes memory. **Empty ⇒ dormant** in external mode. This is the single field that switches the external pack on. AJ's local example: `http://localhost:9177`. See [API URL vs UI/dashboard URL](#api-url-vs-uidashboard-url). |
+| `uiUrl` | string (optional) | — | Optional, **non-secret** human-facing Hindsight **dashboard** URL. Display-only — it is used for the in-app embedded dashboard and is **never dialed by the client** and **never** influences activation/dormancy (those stay keyed on `externalUrl`). Never fabricated from `externalUrl` (different port/path). AJ's local example: `http://localhost:19177/banks/hermes?view=data`. Validated as an http(s) URL; `""` clears it. |
+| `apiKey` | secret (optional) | — | Bearer token. Sent as `Authorization: Bearer ` **only when set**; never echoed back (the `config` GET surface collapses it to a boolean `apiKeySet`). Also forms `ctx.runtime.headers` for the managed API. |
+| `llmApiKey` / `externalDatabaseUrl` / `dataDir` | secret / secret / string | — / — / `~/.hindsight` | **Managed-mode only.** `llmApiKey` → `HINDSIGHT_API_LLM_API_KEY`, `externalDatabaseUrl` → `HINDSIGHT_API_DATABASE_URL` (redacted to `*Set` booleans on the GET surface), `dataDir` is the managed-Postgres bind path. See [managed-runtimes.md — P3](managed-runtimes.md#secrets--config-mapping). |
| `bank` | string | `bobbit` | The shared memory bank id (see [Bank & tag taxonomy](#bank--tag-taxonomy)). |
| `namespace` | string | `default` | Hindsight namespace path segment. |
-| `recallScope` | enum `project` \| `all` | `all` | `all` recalls across the whole bank (cross-project); `project` adds a `project:` tag filter. |
+| `recallScope` | enum `project` \| `all` | `project` | Default recall scope. `project` adds a `project:` tag filter with `tagsMatch` (project + global memories); `all` recalls across the whole bank (cross-project). |
+| `tagsMatch` | enum `any` \| `any_strict` | `any` | Scope filter strategy for `project` scope. `any` includes both project-specific AND global/shared memories. `any_strict` excludes global memories, enforcing hard project-only isolation. |
| `autoRecall` | boolean | `true` | When false, the recall hooks contribute no blocks. |
| `autoRetain` | boolean | `true` | When false, the retain hooks store nothing. |
+| `retainEveryNTurns` | number | `5` | Cadence for background memory extraction. Bobbit runs an expensive LLM extraction once every N turns to optimize cost. |
+| `retainMaxDelayMs` | number | `1800000` | Hook-observed timeout in milliseconds (30m) to flush buffered turns, preventing memories from staling in long-running or inactive sessions. `0` disables time-based flush. |
+| `retainOverlapTurns` | number | `2` | Number of previous turn summaries to carry forward as bounded context/overlap into the next batch. |
| `recallBudget` | number | `1200` | Token budget passed as `max_tokens` to recall (bounds the upstream payload; host-side budgeting still applies). |
-| `timeoutMs` | number | `1500` | Per-request abort budget for the REST client. |
+| `recallTypes` | array of `observation` \| `world` \| `experience` | `["observation", "world", "experience"]` | Filters memory recall to bias toward consolidated/stable knowledge over turn chatter. |
+| `recallQueryTimestampEnabled` | boolean | `true` | Sends an ISO `query_timestamp` anchor on recall so relative-time queries resolve against the current turn/session time. |
+| `mentalModelEnabled` | boolean | `true` | Enables per-project mental model injection at `sessionSetup`; empty/missing models fall back to raw recall. |
+| `mentalModelMaxTokens` | number | `1000` | Max tokens requested for the injected project mental model. |
+| `mentalModelRefreshEveryMs` | number | `86400000` | Best-effort refresh cadence for existing mental models. `0` disables cadence-based refresh. |
+| `mentalModelRecallMaxTokens` | number | `1200` | Recall budget used by Hindsight's model generation trigger. |
+| `directivesEnabled` | boolean | `false` | Enables Bobbit-owned bank directive writes. Off by default because shared banks may apply directives globally. |
+| `directiveApplyMode` | enum `disabled` \| `bank-wide-explicit-opt-in` \| `scoped-if-supported` | `disabled` | Controls whether Bobbit writes `/directives`; non-disabled modes are explicit operator opt-ins. |
+| `directiveSetVersion` / `directives` | string / list | `bobbit-v1` / Bobbit coding-agent directive | Stable directive set and payload used only when directive writes are enabled. |
+| `retainQueueDrainMaxPerHook` | number | `1` | Max queued retain entries retried during `afterTurn`. |
+| `retainQueueShutdownMax` | number | `10` | Max queued retain entries retried during `sessionShutdown`. |
+| `retainQueueHealthGate` | boolean | `true` | Checks `/health` before draining the retain queue. |
+| `retainQueueLlmHealthGate` | boolean | `false` | Optional `/health/llm` check before queue drains. |
+| `retainQueueBatchPauseMs` | number | `0` | Reserved pacing knob for future drains; normal hooks do not sleep. |
+| `retainMission` | string | (Defaults to detailed guideline) | Prompt mission guiding Hindsight's extraction logic on what durable knowledge to keep and what noise to ignore. |
+| `observationsMission` | string | (Defaults to detailed guideline) | Prompt mission guiding Hindsight on how to consolidate observations. |
+| `reflectMission` | string | (Defaults to detailed guideline) | Prompt mission guiding Hindsight's synthesis/reflection logic. |
+| `recallMaxInputChars` | number | `3000` | Truncates recall query text to prevent Hindsight backend 400 "Query too long" errors (max 500 tokens). |
+| `timeoutMs` | number | `4000` | Per-request abort budget for the REST client. Provider hooks cap this below their 4500 ms manifest budget. |
The `config` route validates overrides against this schema before persisting; an empty string
clears an optional string (`externalUrl`/`apiKey`), and numeric keys must be positive.
+#### Per-Project Config Overrides & Precedence
+
+To balance unified global knowledge with project-specific control, Bobbit supports cascading configuration. Overrides resolve with the following precedence (highest to lowest):
+
+1. **Per-Project Config Overlay** (persisted in the pack store under a per-project key)
+2. **Server-Global Config** (configured globally via panel / store)
+3. **Defaults** (`CONFIG_DEFAULTS` / YAML definition)
+
+Only safe, **memory-quality** keys are overrideable per-project:
+- `recallScope`
+- `bank`
+- `tagsMatch`
+- `recallBudget`
+- `recallTypes`
+
+**Hard Server-Global Lock:** A project overlay can *never* override system-level or infrastructure config keys. The deployment `mode`, `externalUrl`, `uiUrl`, and secrets (`apiKey`, `llmApiKey`, `externalDatabaseUrl`, `dataDir`) are strictly locked to the server-global config to prevent security and configuration drift.
+
+**Inherit & Clear Behavior:** Any overridden key set to an empty value (`null` or `""`) is omitted from the project overlay, causing it to transparently inherit from the server-global config.
+
+**Marketplace Config Hydration:** The Marketplace global config form hydrates strictly from `globalConfig` (the un-overlaid, server-global settings). This ensures that any active per-project override does not masquerade as a global setting, preventing it from being accidentally written back globally during save. The effective, overlaid configuration is reserved exclusively for the runtime status/summary rendering and session execution.
+
## Bank & tag taxonomy
**One shared, tag-scoped bank.** All Bobbit memory lives in a single Hindsight bank, id from
@@ -97,50 +147,139 @@ context and flattens them to Hindsight's `string[]` item tags as `":
| `goal:` | `ctx.goalId` | |
| `agent:` | `ctx.roleName` | The contributing agent's role. |
| `session:` | `ctx.sessionId` | |
-| `kind:turn` / `kind:compaction` | derived | `turn` for `afterTurn`, `compaction` for `beforeCompact`. The `retain` pack route tags manual writes `kind:manual`. |
+| `kind:turn` / `kind:compaction` / `kind:outcome` | derived | `turn` for `afterTurn`, `compaction` for `beforeCompact`, `outcome` for `goalCompleted`. The `retain` pack route tags manual writes `kind:manual`. |
+| `bobbit:true` | provider/tool outcome digests | Marks Bobbit-owned outcome records. |
+| `pr:` | goal/PR completion context | Added to outcome digests when PR metadata is known. |
**Recall scope.**
-- `all` (default) — recall across the whole `bobbit` bank with **no project filter**. This is the
- cross-project value: a query like "how did we configure X?" can surface a memory from any
- project.
-- `project` — add a `project:` tag filter (`tags_match: "any"`, so untagged org-wide
- memories still surface). The filter is applied **only when configured**; the default never
- narrows.
+- `project` (default) — add a `project:` tag filter. Under default project scope with no extra tags, the query continues to use the configured `tagsMatch` (which defaults to `"any"`).
+ - Under `"any"`, the query fetches project-tagged **plus** untagged/global memories, excluding only memories tagged for other projects.
+ - Under `"any_strict"`, untagged/global memories are excluded, enforcing hard project isolation.
+ The filter is applied only when the session is associated with a real project ID; a global/server-scope session continues to recall globally.
+- `all` — recall across the entire bank with no project filter. This allows cross-project semantic queries like "how did we set up the database in project X?" to surface knowledge across the entire installation.
+
+Recall, `reflect`, and the agent tools all route this scope→tag decision through one shared
+`recallTagFilter(scope, projectId, tagsMatch, extraTags)` helper (`market-packs/hindsight/src/shared.ts`),
+so every read path resolves project scope identically.
+
+**Extra tags and narrowing semantics:**
+When optional flat `extraTags` are supplied to the helper (e.g., via the agent tools under project scope), they **narrow** results rather than broadening them. The helper overrides `tagsMatch` to `"all_strict"` (requiring all specified tags). Thus, the query matches the current project tag **plus** every extra tag, while strictly excluding untagged/global memories and other-project memories that happen to share that extra tag.
+Furthermore, the route-derived project ID is authoritative: any caller-supplied `project` key inside the `extraTags` is completely ignored and dropped under project scope to prevent callers from overriding the active project scope.
The provider calls the idempotent `client.ensureBank(bank)` before each retain path, so
correctness never depends on once-per-session in-memory state (provider workers are per-hook and
stateless).
+## Memory v2 mechanics reference
+
+This section summarizes the pure memory mechanics added after the initial external-mode pack.
+Implementation details and the validation cost model live in
+[hindsight-integration-brief.md](hindsight-integration-brief.md).
+
+### Per-project mental model
+
+At `sessionSetup`, Bobbit first attempts to read or create a Hindsight mental model with a stable id
+`bobbit-`. If the model has content, the provider injects one **Project memory model**
+block and does **not** run raw session-start recall. If the model is empty, pending async creation,
+skipped by config/no project id, or failed, Bobbit falls back to the previous raw recall path. Refresh
+is best-effort and cadence-bounded by `mentalModelRefreshEveryMs`.
+
+### Recall and reflect request shape
+
+Provider and route recall are observation-biased (`recallTypes`) and timestamp-anchored
+(`query_timestamp`) by default. They intentionally omit `include` so Hindsight 0.8.3 keeps raw
+chunks disabled unless a future explicit caller asks for them.
+
+Reflect supports structured synthesis: `responseSchema` maps to Hindsight `response_schema`, and a
+returned `structured_output` is surfaced as `structuredOutput` by the route and tool. `factTypes`,
+`excludeMentalModels`, `budget`, and `maxTokens` pass through when supplied.
+
+### Outcome digests and advanced retain fields
+
+Goal completion dispatches a non-fatal `goalCompleted` lifecycle hook after team completion. The
+Hindsight provider retains one async digest per completed goal/head using `document_id: "outcome:"` and `update_mode: "replace"`. The digest includes PR, task, gate, branch,
+head-SHA, decision, and touched-file summaries when available.
+
+Retain now forwards advanced Hindsight item fields: `document_id`, `update_mode`, `entities`,
+`timestamp`, `observation_scopes`, and `metadata`. File paths become `{ text, type: "file" }`
+entities; components become `{ text, type: "component" }`. Project observation scopes use the
+nested shape `[["project:"]]`.
+
+### Directives and curation
+
+Bank-wide Bobbit directives are disabled by default because shared banks may apply directives beyond
+Bobbit-tagged calls. Operators can explicitly opt in with `directivesEnabled` and a non-disabled
+`directiveApplyMode`; otherwise, reflect uses a per-request coding-agent instruction prefix.
+
+Agents can retire stale memories with `hindsight_invalidate`, which patches the memory to
+`state: "invalidated"` with a reason. It is reversible curation, not deletion.
+
+## Retain Hygiene & Cost Levers
+
+Memory extraction is highly valuable but historically expensive, as running LLM-based fact-extraction on every turn drives up token consumption and host load. Bobbit implements several robust levers to keep memory high-signal and extremely cost-efficient:
+
+### 1. Bank Missions (Durable Knowledge Steering)
+Hindsight uses explicit prompts to guide memory extraction, observation consolidation, and reflection. Bobbit configures these to actively filter out transient developer noise and steer the engine toward lasting, reusable engineering knowledge:
+- **`retainMission`**: Directs extraction to capture user and team preferences, architecture choices, standards, conventions, and stable project decisions. It explicitly discards ephemeral chatter, greetings, timestamps, PIDs, stack traces, and failed CLI runs.
+- **`observationsMission`**: Tells Hindsight to consolidate recurring facts into general, stable, reusable statements rather than maintaining a noisy timeline of turn histories.
+- **`reflectMission`**: Directs the reflection synthesizer to ground its answers in consolidated observations and documented decisions, ignoring short-term conversational noise.
+
+These are applied idempotently to the Hindsight bank config API. A signature of the current missions is cached in the pack store, avoiding redundant PATCH calls on every turn.
+
+### 2. Batched Retain Cadence
+- **Durable Per-Session Pending Buffer**: Instead of running an expensive LLM extraction request on *every* turn, Bobbit holds turn summaries in a durable `PendingBuffer` JSON structure inside the pack-scoped store. Because provider workers terminate after every hook invocation, this state is saved to disk per session, ensuring that all conversations are processed linearly without memory loss (never randomly sampled).
+- **`retainEveryNTurns` (default: 5)**: A full LLM extraction is run only once every `N` primary turns. At the default of $N=5$, this yields an immediate **80% reduction in routine extraction LLM calls** and associated token costs.
+- **`retainMaxDelayMs` (default: 1,800,000 ms / 30 minutes)**: To prevent memories from staling in long-running or inactive sessions, this threshold acts as a max delay timeout to trigger an aggregate flush.
+ - *Hook-Observed evaluation*: This timer is **not** an exact system-level idle sweeper or background thread interval. Instead, it is evaluated defensively per session on the invocation of provider hooks (such as `afterTurn`), by checking whether the oldest pending turn in the buffer has aged past `retainMaxDelayMs` relative to the current timestamp.
+- **Aggregate Flush Semantics**:
+ - *Triggering*: An aggregate flush occurs when the count of pending primary turns reaches `retainEveryNTurns` OR the age of the oldest pending turn exceeds `retainMaxDelayMs`.
+ - *Content Composition*: The aggregate content joins any carried-forward overlap context from the previous flush (`Earlier context (overlap):` followed by the summaries) with the pending primary turns, separated by the aggregate separator.
+ - *Durable Queueing on Failure*: If the aggregate retain request fails (e.g. network timeout or backend unavailability), the entire built aggregate is enqueued to the durable retry queue so it is never dropped, and a non-fatal error is logged.
+ - *Buffer Advancement*: In both success and failure cases, the buffer is immediately advanced: the primary turns are cleared (so the turn count resets and advances), and the last `retainOverlapTurns` summaries of the primary turns are carried forward as a bounded `overlap` context for the next batch. Carrying forward only the primary summaries prevents previous overlaps from accumulating indefinitely.
+- **`retainOverlapTurns` (default: 2)**: Preserves overlapping turn context at batch boundaries, carrying forward the last `K` summaries of the primary turns as bounded context to maintain thread continuity.
+- **Compaction Safety (`beforeCompact`)**: Before the gateway compacts a session's history and discards the oldest context, the provider intercepts this event via `beforeCompact` to guarantee zero context loss:
+ - *Synchronous Flush*: It first performs a **synchronous flush/retain** (`sync: true`) of any pending turns currently held in the session buffer to ensure they land in Hindsight before context is pruned.
+ - *Synchronous Retain*: It then performs a **synchronous, batch-exempt retain** (`sync: true`, "compaction" kind) of the compaction summary itself, ensuring the about-to-be-lost history span is durably written to Hindsight.
+- **Session Shutdown**: On `sessionShutdown`, Bobbit performs a bounded best-effort drain:
+ - First, it flushes any remaining buffered turns (`flushPending`, `sync: false`) to Hindsight.
+ - Then, it drains at most `retainQueueShutdownMax` queued entries after the health gate passes, so shutdown cannot burst a large queue into an unhealthy daemon.
+
+### 3. Mental Model & Outcome Cost Levers
+- **Per-project mental model**: At `sessionSetup`, Bobbit prefers one Hindsight-maintained project state document over broad raw recall. When the model is injected, session setup makes zero raw recall calls for that hook; when the model is absent, pending, skipped, or failed, Bobbit falls back to raw recall.
+- **Goal/PR outcome digest**: On goal completion, Bobbit retains one concise replaceable digest (`document_id: outcome:`, `update_mode: replace`) that includes PR/task/gate summaries and touched file entities. Future sessions can discover completed work without replaying every turn.
+
## Provider lifecycle behaviour
-The provider implements the five [Lifecycle Hub](lifecycle-hub.md) hooks. It runs on the Extension
+The provider implements six [Lifecycle Hub](lifecycle-hub.md) hooks. It runs on the Extension
Host worker tier, reads merged config from `ctx.config`, builds a REST client per hook, and keeps
all durable state in the pack-scoped `ctx.host.store`. Every Hindsight condition is **non-fatal**:
a slow or unhealthy backend never blocks or fails a session — recalls skip and retains queue.
| Hook | Behaviour |
|---|---|
-| `sessionSetup` | If `autoRecall`: recall against the goal/task spec (`ctx.prompt`) and inject the results as a **"Relevant memory"** context block (`authority: "memory"`). On error/timeout ⇒ no block + a diagnostic. |
-| `beforePrompt` | If `autoRecall`: recall against the current user turn (`ctx.prompt`) under the provider `timeoutMs` deadline; skip on timeout (non-fatal). Same block mapping. |
-| `afterTurn` | If `autoRetain`: build a compact turn summary (user + final assistant text, capped ~2000 chars) and **async** retain it (fire-and-forget). On failure, enqueue for retry. Also drains one [retry-queue](#retry-queue--diagnostics) head per call. |
-| `beforeCompact` | If `autoRetain`: **synchronously** retain a summary of the about-to-be-lost span, so the memory lands before context is dropped. Failure ⇒ enqueue. |
-| `sessionShutdown` | Best-effort **one-pass** drain of the retry queue. Never throws. |
+| `sessionSetup` | If `autoRecall`: try the per-project mental model first. `injected` returns one **"Project memory model"** block (`authority: "memory"`, higher priority than raw recall) in the spawn-time system prompt and suppresses raw recall for that hook. `empty`, `skipped`, or `failed` falls back to raw recall against `ctx.prompt`. |
+| `beforePrompt` | If `autoRecall`: targeted recall against the current user turn (`ctx.prompt`) under `timeoutMs`; skip on timeout (non-fatal). Recall sends configured `types`, an optional `query_timestamp`, and no default `include.chunks`. Blocks are delivered for that turn as hidden `bobbit:dynamic-context` custom/user-side messages rather than a `systemPrompt` append. |
+| `afterTurn` | If `autoRetain`: build a compact turn summary, append to the pending buffer, and flush one aggregate async retain when count or max-delay thresholds are exceeded. Also health-gates and drains up to `retainQueueDrainMaxPerHook` queued entries. |
+| `beforeCompact` | If `autoRetain`: synchronously flush pending turns, then synchronously retain a compaction summary of the about-to-be-lost span (batch-exempt). Failure ⇒ enqueue. |
+| `sessionShutdown` | If active: flush remaining buffered turns, then health-gate and drain at most `retainQueueShutdownMax` queued entries. Never throws. |
+| `goalCompleted` | If `autoRetain`: retain one async outcome digest with stable `document_id`, `update_mode: replace`, PR/task/gate summaries, entities, metadata, and project observation scopes. Failure ⇒ enqueue when store access exists; goal completion still succeeds. |
The recall hooks return `ContextBlock[]` only — **fencing and `providerId` are the host's job**
-(see [Lifecycle Hub → fencing](lifecycle-hub.md#fencing)). Each block is titled "Relevant memory",
-`authority: "memory"`, `priority: 50`, with `content` a bulleted list of recalled memory text. An
-empty recall produces no block.
+(see [Lifecycle Hub → fencing](lifecycle-hub.md#fencing)). Raw recall blocks are titled "Relevant memory",
+`authority: "memory"`, `priority: 50`, with bulleted memory text. Mental model blocks are titled
+"Project memory model" and use higher priority so project state appears above raw recall. Empty
+recall/model output produces no block.
### Retry queue & diagnostics
-A retain that fails (network/timeout/HTTP) is **not lost**. The provider appends
-`{ content, tags, ts }` to a durable queue in the pack store (key `retain-queue`):
+A retain that fails (network/timeout/HTTP) is **not lost**. The provider appends a durable queue entry in the pack store (key `retain-queue`) containing the content, tags, timestamp, target bank/namespace, and any advanced retain fields (`documentId`, `updateMode`, `entities`, `observationScopes`, `metadata`):
- **Cap 100** — appending past 100 entries drops the oldest (FIFO eviction).
-- **Drain on `afterTurn`** — each turn retries the **queue head** (one entry) before doing the
- turn's own retain; success removes it, failure leaves it.
-- **Drain on `sessionShutdown`** — one best-effort full pass.
+- **Target Routing** — queue entries include the original target `bank` and `namespace` at the time of the failure, ensuring retry attempts route back to the correct destination even if current config has changed. Legacy entries without these fields fall back to the active config.
+- **Health-gated drain** — when `retainQueueHealthGate` is true, Bobbit probes `/health` before queue replay. If `retainQueueLlmHealthGate` is true, it also probes `/health/llm`.
+- **Drain on `afterTurn`** — retries up to `retainQueueDrainMaxPerHook` entries (default 1) before the turn's own retain; success removes entries, failure leaves the head for later.
+- **Drain on `sessionShutdown`** — retries at most `retainQueueShutdownMax` entries (default 10), preventing shutdown bursts.
The queue is durable (not in-memory) precisely because provider workers terminate after every hook
invocation, so an in-memory queue would lose everything between turns. Recall skips, retain
@@ -160,12 +299,267 @@ list) rather than erroring.
| Route | Contract |
|---|---|
| `config` | GET → merged effective config with secrets redacted (`apiKey` collapsed to `apiKeySet`). SET (with body) → validate against the schema, persist overrides to the pack store, return the new effective config. |
-| `status` | `{ configured, mode, healthy, bank, namespace, recallScope, autoRecall, autoRetain, queueDepth, lastError? }`. `healthy` is a fresh `client.health()` probe when configured (short timeout), else `false`. `queueDepth` is the retry-queue length. |
-| `recall` | `{ query, scope? }` → resolves bank + tags and calls `client.recall`; returns `{ memories }`. Manual/diagnostic surface. |
-| `retain` | `{ content, tags?, sync? }` → `ensureBank` + `client.retain` with merged auto-tags (`kind:manual`); returns `{ ok }`. |
-| `reflect` | `{ prompt }` → `client.reflect` → `{ text }`. |
+| `status` | `{ configured, mode, healthy, bank, namespace, recallScope, autoRecall, autoRetain, queueDepth, externalUrl, uiUrl, timeoutMs, recallBudget, lastError? }`. `healthy` is a fresh `client.health()` probe when a data plane is reachable. `queueDepth` is the retry-queue length; secrets are never echoed. |
+| `recall` | `{ query, scope?, tags?, queryTimestamp? }` → resolves bank + tags, clamps query length, sends configured `types`, optional `query_timestamp`, and no default `include.chunks`; returns `{ memories }`. |
+| `retain` | `{ content, tags?, sync?, scope? }` → `ensureBank` + conservative manual retain with merged tags. `kind:manual` is applied last so callers cannot override provenance. |
+| `retain_outcome` | `{ content, goalId? | pr?, files?, components?, entities?, tags?, timestamp? }` → async retain with stable `document_id`, `update_mode: replace`, canonical outcome tags, file/component entities, and project observation scopes. |
+| `reflect` | `{ prompt, scope?, tags?, responseSchema?, factTypes?, excludeMentalModels?, budget?, maxTokens? }` → scoped reflection. Returns `{ text, structuredOutput? }`; prepends Bobbit per-request instructions unless bank directives are explicitly enabled or the caller disables the prefix. |
+| `invalidate` | `{ id, reason }` → reversible curation via `PATCH /memories/{id}` with `state: invalidated`; no destructive delete path. |
| `banks` | Diagnostic: `client.listBanks()` → `{ banks }`. The pack itself uses one bank. |
+## Agent tools
+
+The pack ships five **agent tools** (Extension Platform **P5**) that give an agent explicit,
+on-demand access to memory — complementing the automatic recall/retain the [provider](#provider-lifecycle-behaviour)
+does every turn. Where the provider is implicit ("inject relevant memory into the prompt"), these
+tools are deliberate: the agent decides *when* to look something up, write something down, synthesize
+an answer, repair an outcome digest, or invalidate a stale memory.
+
+| Tool | Purpose | Parameters | Output |
+|---|---|---|---|
+| `hindsight_recall` | Fetch durable memories matching a query before acting. | `query` (required), `scope?` (`project`\|`all`), `tags?` (simple map) | A numbered list of memory texts, plus the structured route result (`memories`, `count`, `configured`) under `details`. Empty recall ⇒ "No relevant memories found." |
+| `hindsight_retain` | Durably record a decision, preference, or fact. | `content` (required), `scope?`, `tags?` (extra key/value, additive), `sync?` (wait for durability; default `false`) | "Memory retained." on success; an error result otherwise. The route auto-applies a `kind:manual` tag. |
+| `hindsight_reflect` | Get a synthesized answer drawing on accumulated memory, not a raw list. | `prompt` (required), `scope?`, `tags?`, `responseSchema?`, `factTypes?`, `excludeMentalModels?` | Synthesized text plus `structuredOutput` when Hindsight returns schema-shaped JSON. |
+| `hindsight_retain_outcome` | Repair or re-emit a completed goal/PR digest. | `content` plus `goalId` or `pr`; optional `files`, `components`, `entities`, `tags`, `timestamp` | Retains one replaceable outcome document and returns its `documentId`. |
+| `hindsight_invalidate` | Retire a known stale/incorrect memory without deleting history. | `id` and `reason` | Marks the memory invalidated via the pack route. |
+
+These tools live in `market-packs/hindsight/tools/hindsight/` (`extension.ts` plus one descriptor
+YAML per tool); each descriptor declares `provider: { type: bobbit-extension, extension: extension.ts }`.
+
+**Pack-owned — disabling the pack removes them.** The tools are contributed by the pack
+(`pack.yaml` `contents.tools: [hindsight]`), so they appear in a session's tool list only while the
+pack is enabled. Disabling the pack (or just its tools) at any scope removes them from tool
+resolution for sessions created afterward, and the activation gate is closed end-to-end: a disabled
+tool no longer resolves as a market-pack tool, so it cannot even mint a surface token. This is the
+same disable mechanism as any [first-party pack](marketplace.md#built-in-first-party-packs).
+
+**They never call Hindsight directly — they go through the pack routes.** The agent surface is
+deliberately thin. Each tool invocation does exactly two authenticated gateway calls:
+
+1. `POST /api/ext/surface-token` `{ sessionId, tool }` — mint a **tool-bound** surface token
+ (the [tool-guard](#pack-routes) checks the tool is in `allowedTools`, belongs to the calling
+ session, and resolves to a market pack). The server derives `{ packId, tool }` from the minted
+ token, so the route body never carries a pack id.
+2. `POST /api/ext/route/` with the minted `surfaceToken` — dispatch the
+ pack's own [route](#pack-routes) in the confined worker.
+
+The route — not the tool — owns config merge, bank resolution (the single shared bank, default
+`bobbit`), external/managed-mode handling, dormancy, and the scope→tag mapping. **Why route the
+tools through the pack routes instead of letting them talk to Hindsight?** It keeps a single
+authorization path (surface-token + tool-guard) and a single source of truth for bank/scope/config
+behaviour, so the agent tools, the panel's manual search, and the provider all resolve memory the
+same way. A dormant (unconfigured) Hindsight yields a clean signal — recall/reflect return empty,
+retain returns a not-configured error — never a crash.
+
+### `scope` & `tags` → tags on the shared bank
+
+The recall, retain, and reflect tools accept `scope: project | all` (defaulting to the configured `recallScope`, which is now `project`) and an optional flat `tags` map parameter (e.g., `{goal: "implement-auth"}`). Outcome and invalidation tools use their dedicated route semantics instead.
+
+**Scope is a tag filter on the single shared bank (`config.bank`, default `bobbit`) — never a different bank.**
+
+- `recall` / `reflect` — under `project` scope with no extra tags, this adds a `project:` tag filter and resolves using the configured `tagsMatch` (default `"any"`, fetching project-tagged **plus** untagged/global memories, excluding other projects — see [Recall scope](#bank--tag-taxonomy)). When optional extra `tags` are supplied under `project` scope, they **narrow** results with strict `all_strict` semantics (matching the current project tag AND every extra tag, while excluding untagged/global and other-project memories sharing that extra tag). Any caller-supplied `tags.project` value is completely ignored and dropped; the route-derived current project ID is authoritative and cannot be overridden. Under `all` scope, no project tag filter is added, but optional extra `tags` are still applied additively (matching via `"any"`).
+- `retain` — `project` adds a `project:` tag (again only with a real project id) alongside the auto `kind:manual` tag; `all` leaves the memory unscoped on the shared bank. User-supplied `tags` are additive and never change the bank. The `kind:manual` provenance marker is spread **last**, so a user-supplied `tags: { kind: "..." }` can never override it.
+- `retain_outcome` — always uses outcome semantics: canonical `kind:outcome`/`bobbit:true` tags, project/goal/PR tags when known, file/component entities, nested project observation scopes, and replacement by stable `document_id`.
+
+A configured custom `bank` (or `namespace`) flows through every tool to Hindsight unchanged — the scope→tag mapping is orthogonal to which bank is configured. This mirrors the provider's [bank & tag taxonomy](#bank--tag-taxonomy): scope is *always* expressed as tags on one bank, never as bank fan-out.
+
+**No `tag_groups` DSL in Tools:** To keep the tool descriptions compact, budget-compliant, and simple for agents to use reliably, the complex `tag_groups` Boolean tree (AND/OR query tree) is **never** exposed to agent tools.
+
+**Power-User Escape Hatch (Direct API):** For complex, compound Boolean filters (e.g. searching memories matching `(project:A OR project:B) AND kind:decision`), clients should bypass the agent tools and call the direct Hindsight data-plane API (`POST /v1/{namespace}/banks/{bank}/memories/recall` with the full `tag_groups` body).
+
+API E2E coverage lives in `tests/e2e/hindsight-agent-tools.spec.ts` (reusing the shared
+`tests/e2e/hindsight-stub.mjs`): it drives the real surface-token + route round-trip for each tool,
+asserts the scope→tag mapping and default/custom bank routing on the stub, covers structured reflect,
+outcome retain, and invalidation, confirms the tools resolve for a project session, and verifies that
+disabling the pack tools removes them from a newly-created session's tool list (and closes the
+surface-token mint with a 403).
+
+## Embedded Dashboard Tab
+
+The **Hindsight** extension entrypoints are designed for **using, viewing, and querying** memory within the app. Clicking the session-menu entry (**Hindsight Memory**) or navigating to the `#/ext/hindsight` route opens the **live Hindsight dashboard embedded directly as an in-app Bobbit tab/panel** so the user can inspect and search the memory bank without leaving Bobbit.
+
+This is implemented by rendering the configured `uiUrl` in a **sandboxed iframe** inside a first-class side-panel/tab, reusing Bobbit's pack-panel or iframe infrastructure. Because the local Hindsight dashboard runs without frame-protection headers (no local `X-Frame-Options` or Content Security Policy blocking), it embeds cleanly and securely.
+
+### Entrypoints & Navigation
+
+Two entrypoints open this **embedded dashboard tab**, declared under `market-packs/hindsight/entrypoints/` and listed in `pack.yaml` under `contents.entrypoints`:
+
+| Entrypoint | Kind | How to reach it |
+|---|---|---|
+| `hindsight-session-menu` | `session-menu` | A launcher labelled **Hindsight Memory** in the session actions overflow menu, sitting next to **PR Walkthrough**. Its target is a `PanelTarget` (no `action: spawn`), which loads the embedded dashboard iframe for the configured `uiUrl` within the active/owner session. |
+| `hindsight-route` | `route` (`routeId: hindsight`) | Deep link **`#/ext/hindsight`**. Opens the embedded dashboard tab directly, rehydrating the view from the routes. |
+
+### Failure Paths & Safe Fallbacks
+
+The embedded dashboard tab is robust against misconfiguration or environment failures:
+- **Unset `uiUrl`**: If the `uiUrl` setting is empty, the tab does not dead-end. It displays a helpful Call-to-Action (CTA) pointing the user to configure Hindsight in the Marketplace, alongside any available API-only or external status context.
+- **Blocked or Unreachable iframe**: If a remote or secured Hindsight deployment serves frame protection headers (blocking the iframe) or is network-unreachable, the tab detects this and renders a clear warning.
+- **Secondary Fallback**: The primary **"Open Hindsight UI"** action opens the embedded in-app dashboard; an external-browser fallback link is provided as a secondary option when `uiUrl` is configured, while an unset `uiUrl` displays helpful Marketplace guidance.
+
+### Move configuration out of the entry, into the Marketplace
+
+To streamline the user experience, configuration has been completely moved out of the session entry point and consolidated inside the **Marketplace**. The standalone config/status form that the entry used to open is no longer the entry's job, leaving the entry focused entirely on utilizing the embedded dashboard.
+- **Marketplace inline form/wizard**: All settings (deployment mode, URLs, bank, scope, and auto-toggles) and write-only secrets management are performed strictly within the Marketplace row's inline form.
+- **Read-only status card**: Genuine runtime status metrics (such as mode, health, and retry-queue depth) are visible in the Marketplace row itself.
+
+The panel uses only Bobbit theme tokens (`--background`, `--foreground`, `--card`, `--border`,
+`--primary`, `--muted-foreground`, the `--chart-*` palette, and the `--positive`/`--negative`/
+`--warning` semantic slots via `color-mix`) — no hardcoded palette. Browser coverage lives in
+`tests/e2e/ui/hindsight-pack.spec.ts` (reusing the shared `tests/e2e/hindsight-stub.mjs`): open
+from the session menu or `#/ext/hindsight`, verify the iframe uses the configured `uiUrl`, assert
+that the entry is not a configuration form, cover unset/blocked iframe fallback states, and verify
+persistence across reload via the `#/ext/hindsight` deep link.
+
+## Setup UX — Marketplace front door, state model & guided setup
+
+The UX-polish pass makes the **Marketplace installed row** the primary setup path and gives both
+surfaces a single, unambiguous state vocabulary. The full UX spec (with an interactive prototype)
+is [docs/design/hindsight-ux-polish.md](design/hindsight-ux-polish.md); this section documents the
+shipped behaviour.
+
+**Why a richer row?** Before this pass the built-in `hindsight` row collapsed to a single generic
+`Enabled` lozenge, hiding the distinctions that actually matter for a memory backend (configured vs
+dormant, external connected vs unreachable, managed stopped vs running). The row is now the *front
+door* (what state is memory in, what's the next safe action); the [panel](#native-config--status-panel)
+is the *workbench* (detailed config, search, logs).
+
+### State model — one source, two surfaces
+
+Both the Marketplace row and the panel derive their badge from the **same** inputs (`mode`,
+`configured`, `healthy`, and a managed `runtimeStatus` from the supervisor) so they can never
+disagree. The marketplace helper is `deriveHindsightState(...)` in
+`src/app/marketplace-page.ts`; the managed runtime status comes from `GET /api/pack-runtimes`.
+
+| State | Trigger | Badge | Token |
+|---|---|---|---|
+| **Disabled** | pack/provider toggled off | `Disabled` | `--muted-foreground` |
+| **Dormant / not configured** | enabled, external mode, no `externalUrl` (or managed not yet configured) | `Not configured` | `--warning` |
+| **External · Connected** | external, `externalUrl` set, `healthy` | `Connected (external)` | `--positive` |
+| **External · Unreachable** | external, `externalUrl` set, `!healthy` | `Unreachable (external)` | `--negative` |
+| **Managed · Stopped** | managed, configured, runtime `stopped` (or `docker-unavailable`) | `Stopped (managed)` | `--muted-foreground` |
+| **Managed · Starting** | managed, runtime `starting` | `Starting (managed)` | `--info` |
+| **Managed · Running** | managed, runtime `running` + `healthy` | `Running (managed)` | `--positive` |
+| **Managed · Unhealthy** | managed, runtime up but health probe failing | `Unhealthy (managed)` | `--negative` |
+
+Colour is **never** the only signal — each state pairs the semantic token with a distinct icon and
+a plain-language one-liner. A transient **Checking…** state renders until the first `status` load
+resolves.
+
+**Sessionless status read.** After navigating to `#/market` there is no active chat session, so the
+normal surface-token route path (`/api/ext/surface-token` → `/api/ext/route`) would 403. The row
+reads the built-in pack's read-only routes through an additive, narrowly scoped seam
+`GET /api/ext/pack-route/:packId/:routeName`: **admin-bearer only**, **GET only** (it can never
+persist), and **built-in first-party packs only**. Managed-runtime context is resolved **without
+starting Docker**, preserving the no-auto-start invariant.
+
+### Active configured values surfaced
+
+Both surfaces show the live, persisted config (a read-only projection of the `status` route — no
+secrets, only `*Set` chips): **data-plane API URL**, **UI/dashboard URL**, **bank**, **namespace**,
+**recall scope**, **auto-recall / auto-retain** toggles, **timeout**, **recall budget**, and the
+retry-**queue depth** (plus `lastError` as a muted diagnostic when present). The marketplace row
+renders these compactly; the panel renders them in its status card.
+
+### API URL vs UI/dashboard URL
+
+Users conflate the two URLs, so the UI distinguishes them everywhere:
+
+- **API URL** (`externalUrl`) — the Hindsight **data-plane API**, where Bobbit reads and writes
+ memory. This is the only URL the client ever dials, and the field that switches external mode on.
+ AJ's local example: `http://localhost:9177`.
+- **UI / dashboard URL** (`uiUrl`) — the human **web dashboard** for browsing memory. Bobbit **never** reads through it. The primary **Open Hindsight UI** action opens the embedded in-app dashboard route (`#/ext/hindsight`). A secondary link to open in an external browser is provided when `uiUrl` is configured. If `uiUrl` is unset, the interface directs the user with helpful Marketplace configuration guidance. It is optional, non-secret, and **never fabricated** from the API URL (different port/path). AJ's local example: `http://localhost:19177/banks/hermes?view=data` (Tailscale equivalent: `http://:19177/banks/hermes?view=data`).
+
+### Actions (state-aware)
+
+Each action appears only where it is meaningful; all map to existing routes (plus the sessionless
+read seam above). At most a few buttons render inline.
+
+| Action | Where shown | Effect | Backing call |
+|---|---|---|---|
+| **Configure** | always (primary) | Opens the guided setup wizard / inline configuration form inside the Marketplace | opens Marketplace inline configure form / wizard |
+| **Test connection** | when configured | Re-reads the `status` route (pure health probe, no Docker) and shows an inline ok/fail lozenge | sessionless `status` read |
+| **Open Hindsight UI** | always | Opens the embedded dashboard tab inside Bobbit (displays helpful Marketplace guidance if uiUrl is unset; includes a secondary link to open externally if uiUrl is configured) | in-app navigation (iframe target) |
+| **Start runtime** | managed + stopped | **Explicit** consented Docker start (gated by the consent disclosure) | `POST /api/pack-runtimes/:id/start` |
+| **Stop runtime** | managed + running/starting/unhealthy | Stops containers, keeps data | `POST /api/pack-runtimes/:id/stop` |
+| **View logs** | managed modes | Inline read-only log tail | `GET /api/pack-runtimes/:id/logs?tail=` |
+
+External mode never shows Start/Stop/View-logs (there is no Bobbit-managed process). **Test
+connection** never starts Docker — it is a pure read.
+
+### Guided setup walkthrough
+
+**Configure** opens the guided setup wizard inside the Marketplace. This walkthrough explains the configuration choices, recommends safe defaults, validates settings, and guides you through the setup process. It is a user-friendly wrapper over the underlying `config` + `pack-runtimes` routes.
+
+Step 0 is a four-card deployment chooser that specifies exactly what Bobbit manages vs what you manage. All cards are fully selectable, and selecting any mode cleanly advances the wizard to the appropriate next step:
+
+| Choice | `mode` | Bobbit manages | You manage |
+|---|---|---|---|
+| **Bobbit-managed (recommended)** | `managed` | Docker: Hindsight API + Postgres | An LLM API key; a data dir |
+| **Bobbit-managed + your Postgres** | `managed-external-postgres` | Docker: Hindsight API | A Postgres URL; an LLM key |
+| **Connect existing Hindsight** | `external` | Nothing (client only) | The whole Hindsight deployment |
+| **Hermes-local / embedded** | `external` (preset) | Nothing | Hermes runs Hindsight for you |
+
+The **Hermes-local** card is a preset that bakes AJ's local development values. Selecting any preset or mode only edits your local setup draft — **it never starts Docker**.
+
+#### Per-Mode Actionable Steps & Guidance
+To ensure the setup experience matches what is actually happening under the hood, the steps and actions in the wizard dynamically adjust based on the selected mode:
+- **Managed Modes (`managed` and `managed-external-postgres`)**: Because Bobbit manages the Docker containers, the wizard displays the consent disclosure and requires you to input necessary secrets (like the LLM API key). It culminates in an explicit, consent-gated **Start Runtime** step and button. This triggers the Docker compose workflow (pull → create → start → health check → smoke test) and is the only path that launches the managed Docker process.
+- **External Mode (`external`)**: In this mode, Hindsight is managed entirely by you externally. Because there is no Bobbit-managed runtime to boot, the wizard **does not promise or show a Start Runtime button**. Instead, the final step presents a **Test Connection** button, which performs a non-blocking reachability and recall smoke test (retaining is never auto-fired during smoke tests) to verify Bobbit can successfully communicate with your external Hindsight data-plane API.
+
+### Recommended defaults
+
+The walkthrough surfaces an opinionated, safe defaults explainer (rationale shown inline):
+
+| Setting | Default | Rationale |
+|---|---|---|
+| Data locality | local / private | Your memory stays on your machine unless you point at a shared deployment. |
+| Bank | `bobbit` (shared) | One shared, tag-scoped bank. Use an existing bank like `hermes` only when connecting to one. |
+| Namespace | `default` | Leave as `default` unless your Hindsight uses namespaces. |
+| Auto-retain | on (async) | Memories are saved in the background after each turn — no latency cost. |
+| Auto-recall | on | Relevant memories are pulled in automatically at session start and each turn. |
+| Recall scope | `project` | This project + shared/global memories — "have we solved this before in this project, or globally?" |
+| Timeout | `4000 ms` | Capped below the 4500 ms provider budget; slow recall fails open and retains queue. |
+| LLM key (managed) | none (user-supplied) | Hindsight uses your LLM key for extraction. Bobbit forwards it to the local runtime only; it never hardcodes a provider secret. |
+
+### Managed mode never auto-starts Docker
+
+A hard, tested invariant (preserving the runtime's `startPolicy: on-enable`): **selecting a managed
+mode never starts Docker.** The UI enforces this in three places:
+
+1. **Mode selection writes config only.** Picking a managed card/preset persists `mode` and shows
+ the runtime as **Stopped** — no `compose up`.
+2. **Explicit Start.** Docker starts only from the **Start runtime** button, gated by the consent
+ disclosure (services, ports, volume path, trust copy) and labelled unambiguously
+ ("Start (starts Docker)"). Start also **requires saved config first** — it is not enabled from an
+ unsaved draft.
+3. **Required-inputs gate.** Start is disabled until the mode's required inputs are present
+ (`llmApiKey` for `managed`; `+ externalDatabaseUrl` for `managed-external-postgres`).
+
+### Stale-form & Save safety
+
+The headline regression this pass fixes: after a config was persisted by any path while the panel
+was open, the status card refreshed but the **form** kept showing stale defaults — so a Save would
+diff the stale draft against the persisted config and **overwrite the good config**. The fix:
+
+- **Refresh re-hydrates both config and status.** `Refresh` (and the post-Save reload) now call
+ `loadConfig` **and** `loadStatus`, so the form and the status card always reflect the same load.
+- **Dirty-aware hydration.** On every `loadConfig`, the diff base (`entry.config`) is always
+ refreshed. If the user has **no unsaved edits**, the editable draft is re-seeded from the freshly
+ loaded config (this alone fixes the repro). If the user **has** unsaved edits, their draft is
+ preserved.
+- **Touched-field Save body.** Save POSTs **only fields the user actually touched** (non-secrets via
+ `touched`, secrets via `secretTouched`) — never a diff of the whole draft — so a stale, untouched
+ field can never be sent as a "change".
+- **Fail-fast pre-save refresh.** Before building the body, Save re-reads the live config to refresh
+ the diff base; if that refresh fails it **fails fast** rather than proceeding from a stale
+ snapshot and clobbering a memory-backend URL.
+
+Browser E2E coverage for the whole UX pass lives in `tests/e2e/ui/hindsight-marketplace.spec.ts`
+and `tests/e2e/ui/hindsight-pack.spec.ts` (both reuse the shared `tests/e2e/hindsight-stub.mjs`):
+first-run Configure, guided-setup defaults/explanations, external connected/unreachable states, the
+stale-form refresh regression, the Open-Hindsight-UI action, managed no-auto-start behaviour, and
+progress/status rendering against mocked runtime events.
+
## REST client
`market-packs/hindsight/src/hindsight-client.ts` is a thin, faithful mapping over the Hindsight
@@ -173,7 +567,7 @@ HTTP API (`/v1/{namespace}/banks/{bank}/…`). Body shapes are mapped per the up
(Hindsight 0.8.x); see [the design doc §3](design/hindsight-pack-external.md) for the exact request
and response mapping. Behaviour pinned by `tests/hindsight-client.test.ts`:
-- Every method arms an `AbortController` with `timeoutMs` (default 1500); an abort surfaces as
+- Every method arms an `AbortController` with `timeoutMs` (default 4000); an abort surfaces as
`HindsightError{ kind: "timeout" }` thrown **within budget**.
- Non-2xx ⇒ `HindsightError{ kind: "http", status }`; DNS/connection/socket failure ⇒
`HindsightError{ kind: "network" }`.
@@ -181,20 +575,29 @@ and response mapping. Behaviour pinned by `tests/hindsight-client.test.ts`:
- `health()` is the sole exception that swallows errors — it is a pure reachability probe mapping
every failure to `{ ok: false }`. Dormancy and skip-on-failure are the **provider's** job, so the
client surface stays a faithful mapping.
+- v2 methods cover mental models, advanced retain fields, structured reflect, directives,
+ operations/LLM health, and reversible memory invalidation; exact request bodies are pinned in
+ `tests/hindsight-client.test.ts` and summarized in
+ [hindsight-integration-brief.md](hindsight-integration-brief.md).
## Testing
| Test | Phase | What it pins |
|---|---|---|
-| `tests/hindsight-client.test.ts` | unit | Client round-trips, typed errors, timeout-within-budget, auth-header-only-when-set, namespace path-building (vs the in-process stub). |
-| `tests/hindsight-provider.test.ts` | unit | Dormancy (no URL ⇒ no client constructed), auto-tag taxonomy, `recallScope` filter, retry-queue retry + cap, block shape. |
-| `tests/e2e/hindsight-external.spec.ts` | E2E | sessionSetup + beforePrompt blocks appear; a turn retains on the stub with bank `bobbit` + correct tags; unhealthy ⇒ session unaffected + diagnostic + `status` unhealthy; recovery flushes the queue; per-project disable ⇒ no injection; persists across reload. |
+| `tests/hindsight-client.test.ts` | unit | Client round-trips, typed errors, timeout-within-budget, auth-header-only-when-set, namespace path-building, mental-model/directive/operation/curation paths, chunks-off recall mapping, structured reflect, and advanced retain fields. |
+| `tests/hindsight-provider.test.ts` | unit | Dormancy, auto-tag taxonomy, `recallScope` filter, mental-model injection/fallback/refresh, timestamped chunk-free recall, batched retain, health-gated bounded queue drain, outcome digest idempotency, and queue preservation of advanced fields. |
+| `tests/lifecycle-hub.test.ts` / `tests/team-manager.test.ts` | unit | Non-fatal `goalCompleted` dispatch, provider hook context, persisted once-per-goal/head markers, and concurrent completion collapse. |
+| `tests/e2e/hindsight-external.spec.ts` | E2E | Configured provider behavior against the stub, including memory injection, retain/queue recovery, project disable, reload persistence, and Hindsight route behavior. |
+| `tests/e2e/hindsight-agent-tools.spec.ts` | E2E | P5 agent tools round-trip through the real surface-token + route path: recall/retain/reflect scope mapping, structured reflect, outcome retain, invalidation, default/custom bank routing, and disabled-pack denial. |
+| `tests/e2e/ui/hindsight-pack.spec.ts` | E2E (browser) | The native panel: open from the palette, Save, status flips to connected, search; **plus** the UX-polish [stale-form refresh regression](#stale-form--save-safety) and guided-setup behaviour. |
+| `tests/e2e/ui/hindsight-marketplace.spec.ts` | E2E (browser) | The Marketplace [state model](#state-model--one-source-two-surfaces) and [actions](#actions-state-aware): first-run Configure, connected/unreachable badges, Open-Hindsight-UI action, managed no-auto-start, and progress/status rendering against mocked runtime events. |
| `tests/manual-integration/hindsight-external.test.ts` | manual | Real local Hindsight round-trip. |
The shared in-process stub `tests/e2e/hindsight-stub.mjs` (`startHindsightStub`) backs the
automated tests deterministically — no network. It records every call, serves seeded memories
-filtered by request tags, records retained items, and `setHealthy(false)` flips `/health` to 503 so
-the provider's skip/queue paths are exercised.
+filtered by request tags, records retained items with replacement semantics, supports mental models,
+directives, operations, LLM health, structured reflect, and invalidation, and `setHealthy(false)`
+flips `/health` to 503 so skip/queue paths are exercised.
### Manual integration against a real Hindsight
@@ -226,15 +629,30 @@ entry in `PACKS`, `platform: "node"`), and `scripts/copy-builtin-packs.mjs` list
`FIRST_PARTY_PACKS` so it ships in the built-in band. The shared `src/shared.ts` is inlined into
both `provider.mjs` and `routes.mjs`; only `lib/` ships, never `src/`.
-## Non-goals
+## Cost & Signal Model (Before vs. After)
+
+The v2 work reduces token and call cost by replacing broad session-start recall when a project
+mental model is ready, preserving existing retain batching, and bounding recovery bursts.
+
+| Dimension | Before v2 | After v2 | Impact / Benefit |
+|---|---|---|---|
+| **Session-start memory** | One raw `sessionSetup` recall could inject up to `recallBudget` tokens (default 1200). | Mental-model `injected` state returns one curated project block capped by `mentalModelMaxTokens` (default 1000) and makes zero raw recall calls for that hook. | Roughly 15-25% direct session-start memory token reduction when the model stays near 900-1000 tokens, plus higher signal. |
+| **Cold/missing model path** | Raw recall only. | Empty, pending, skipped, or failed model states fall back to raw recall. | No cold-start regression. |
+| **Per-turn recall** | Targeted recall, observation-biased, chunk-free by default. | Same, plus default `query_timestamp` anchoring. | Keeps prompt context focused and makes relative-time queries deterministic. |
+| **Routine retain cost** | Batched retain every `retainEveryNTurns` turns (default 5). | Same. | Preserves the existing 80% reduction versus every-turn extraction. |
+| **Completion memory** | Completed work was discoverable only through retained turn history. | One async replace outcome digest per completed goal/head. | Adds one low-frequency write that reduces future rediscovery/search turns. |
+| **Queue recovery** | Head drain during turns; shutdown could attempt a broad one-pass drain. | Health-gated, max `retainQueueDrainMaxPerHook` per turn and max `retainQueueShutdownMax` at shutdown. | Avoids starving/restarting an unhealthy Hindsight daemon. |
+| **Structured answers** | Reflect returned prose only. | `responseSchema` can return compact `structuredOutput`. | Avoids prose parsing and follow-up correction turns for schema-shaped queries. |
+
+Validation commands and audit points are listed in
+[hindsight-integration-brief.md](hindsight-integration-brief.md#validation-commands).
-Tracked in later Extension Platform goals, **not** in this release:
+## Non-goals
-- Explicit agent tools `hindsight_recall/retain/reflect`, the native memory panel, and entry
- points — **G2.3**.
-- Managed Docker runtime + Postgres + `~/.hindsight` bind-mount + deployment-mode selection
- (`mode: managed`) — **G3**.
-- Mental-models / reflect UI / cross-engine dedupe / cost surfacing — **G4**.
+This page covers memory mechanics plus the existing setup/embedded-dashboard references needed to
+orient users. The v2 memory work did **not** add new UI/surfaces, memory-browser filtering, cross-bank
+federation, or a full `tag_groups` DSL in agent tools. Complex Boolean memory queries remain a
+direct Hindsight API escape hatch.
## See also
diff --git a/docs/lifecycle-hub.md b/docs/lifecycle-hub.md
index ffc0366ea..0653479de 100644
--- a/docs/lifecycle-hub.md
+++ b/docs/lifecycle-hub.md
@@ -1,24 +1,25 @@
# The Lifecycle Hub
-> **Status — all five hooks wired (Extension Platform G1.3 + G1.4).**
+> **Status — provider hooks wired.**
> New sessions dispatch `sessionSetup` through the `LifecycleHub`, and the blocks it returns
> render as a **Dynamic Context** prompt section — see
> [Session-setup wiring (G1.3)](#session-setup-wiring-g13). The per-turn `beforePrompt` /
> `beforeCompact` hooks fire from a generated **provider-bridge** pi extension, and `afterTurn` /
> `sessionShutdown` fire from the gateway's own agent-event stream — see
-> [Per-turn + lifecycle wiring (G1.4)](#per-turn--lifecycle-wiring-g14). Built-in production
-> providers include the [Hindsight memory pack](hindsight-memory.md) and PR Walkthrough durable
-> progress provider; both are scoped so an out-of-the-box normal session receives no unrelated
-> Dynamic Context. This page documents the Hub core and its
-> session wiring.
+> [Per-turn + lifecycle wiring (G1.4)](#per-turn--lifecycle-wiring-g14). `goalCompleted` fires
+> non-blockingly after goal completion for outcome side effects. Built-in production providers
+> include the [Hindsight memory pack](hindsight-memory.md) and PR Walkthrough durable progress
+> provider; both are scoped so an out-of-the-box normal session receives no unrelated Dynamic
+> Context. Hindsight also ships with `defaultDisabled: true`, so a fresh unconfigured install still
+> produces no Dynamic Context section. This page documents the Hub core and its session/goal wiring.
## What it is, and why
The Lifecycle Hub is the server-side seam that lets **pack-contributed providers** inject
-*ambient context* into an agent session at well-defined moments — when a session starts, before
-each prompt, after a turn, before a compaction, and at shutdown. A provider is trusted,
+*ambient context* and lifecycle side effects at well-defined moments — when a session starts, before
+each prompt, after a turn, before a compaction, at shutdown, and after goal completion. A provider is trusted,
pack-shipped code (see [provider contributions](marketplace.md#provider-contributions-providersidyaml)
and the [authoring guide](extension-host-authoring.md)); the Hub is what eventually *runs* that
-code and folds its output into the prompt.
+code, folding context-hook output into the prompt and isolating side-effect-only hooks.
The design problem the Hub solves: ambient context is powerful but dangerous. Untrusted *output*
and slow/looping *code* can both wreck a session. So the Hub is built around a single principle —
@@ -53,7 +54,7 @@ The three core modules live under `src/server/agent/`:
## Hooks
-A **lifecycle hook** is a named moment in a session's life. The hook set is:
+A **lifecycle hook** is a named moment in a session or goal's life. The hook set is:
| Hook | Dispatch point | How it fires | Wiring goal | Status |
|---|---|---|---|---|
@@ -63,13 +64,14 @@ A **lifecycle hook** is a named moment in a session's life. The hook set is:
| `afterTurn` | After a turn completes. | Server-side, from the gateway's `agent_end` event. | G1.4 | **wired** |
| `sessionShutdown` | When a session is torn down. | Server-side, from the session archive path. | G1.4 | **wired** |
| `goalProvisioned` | Every time a worktree in a goal's subtree is provisioned (goal worktree, team-member / delegate worktree, pooled worktree, sandbox worktree). | Server-side `dispatchGoalProvisioned`; fire-and-forget, returns no `ContextBlock`s. | — | **wired** |
+| `goalCompleted` | After a goal/team is durably completed. | Server-side `dispatchGoalCompleted`; non-blocking, returns diagnostics only. | — | **wired** |
A provider declares which hooks it wants in its YAML `hooks:` list; the Hub only dispatches a
hook to providers that declared it. The hooks split by **where** they fire:
-- **Server-side hooks** (`sessionSetup`, `afterTurn`, `sessionShutdown`) dispatch directly from
- the gateway with no agent round-trip — they observe lifecycle moments but cannot amend the
- outgoing turn.
+- **Server-side hooks** (`sessionSetup`, `afterTurn`, `sessionShutdown`, `goalCompleted`) dispatch
+ directly from the gateway with no agent round-trip — they observe lifecycle moments but cannot
+ amend the outgoing turn.
- **Per-turn hooks** (`beforePrompt`, `beforeCompact`) must run *inside* the agent process so
they can observe/amend the turn, so they fire via a Bobbit-generated
[provider-bridge pi extension](#the-provider-bridge-extension) that calls back into the
@@ -84,6 +86,10 @@ hook to providers that declared it. The hooks split by **where** they fire:
provider error is logged and swallowed so it never blocks goal/session start. For sandboxed
sessions it is dispatched with **host** worktree coordinates, not the container path. See
[Hierarchical goal metadata → Extension goal-lifecycle hook](design/goal-metadata.md#6-extension-goal-lifecycle-hook).
+- **The completion hook** (`goalCompleted`) is also side-effect-only. It fires after team/goal
+ completion succeeds so providers can retain outcome digests or run cleanup. It is non-blocking
+ from the user's perspective: provider failures are logged as diagnostics and cannot roll back
+ completion.
**Per-goal provider filtering.** When a goal sets `bobbit.disabledProviders: [""]` in its
metadata, the Hub drops those providers from `dispatch`, `hasProvidersForHooks`, and
@@ -344,14 +350,20 @@ The section is added in **both** prompt builders, mirroring the skills-catalog d
(`GET /api/sessions/:id/prompt-sections`) **for free**, with `source: "providers"` provenance and
a token count; per-block `provider` / `reason` / token live inside the fence attributes.
+This `sessionSetup` Dynamic Context remains a **spawn-time system-prompt section**. It is
+cache-safe because it is assembled once for the session instead of changing on every turn. Per-turn
+`beforePrompt` Dynamic Context uses the custom-message path described below and is not appended to
+`systemPrompt`.
+
**Empty / absent `dynamicContext` adds zero sections**, so a session with no contributing provider
produces a byte-identical prompt to before this wiring — the invariant a unit test pins.
### What ships out of the box
The [Hindsight memory pack](hindsight-memory.md) ships in the built-in band as the first production
-provider, but it is **dormant until a Hindsight URL is configured** — so a fresh install contributes
-no Dynamic Context until you opt in. The wiring itself is also exercised by a
+provider, but its pack manifest declares `defaultDisabled: true` — so a fresh unconfigured install
+contributes no Dynamic Context until setup enables/configures it. Already configured Hindsight
+installations preserve activation. The wiring itself is also exercised by a
deterministic fixture pack, `tests/fixtures/packs/provider-demo/`, whose `sessionSetup` returns a
`DEMO_SETUP_BLOCK` and a throwing variant proves the failure path still spawns the session. The
E2E test (`tests/e2e/provider-session-setup.spec.ts`) **copies that fixture into the per-gateway
@@ -364,17 +376,18 @@ the whole worker-scoped gateway and broke sibling specs. Installing any schema-2
## Per-turn + lifecycle wiring (G1.4)
-G1.4 wires the remaining four hooks. They divide cleanly by **where they have to run**, and that
-division dictates the mechanism:
+G1.4 wires the per-turn/session lifecycle hooks. They divide cleanly by **where they have to run**,
+and that division dictates the mechanism:
| Hook | Mechanism | Why |
|---|---|---|
| `afterTurn` | Gateway-internal, fire-and-forget. | A turn-complete signal; nothing to inject, so no agent round-trip is needed. |
| `sessionShutdown` | Gateway-internal, awaited-with-timeout. | Lets providers flush durable state before teardown; the gateway already owns the archive path. |
+| `goalCompleted` | Gateway-internal, non-blocking after completion. | Lets providers retain outcome/cleanup side effects without affecting completion success. |
| `beforePrompt` | In-process provider-bridge extension. | Must inject ambient recall into *this turn's* prompt, which only the agent process can see. |
| `beforeCompact` | In-process provider-bridge extension. | Must fire at the agent's compaction moment, which the gateway does not observe directly. |
-### Server-side hooks: `afterTurn` and `sessionShutdown`
+### Server-side hooks: `afterTurn`, `sessionShutdown`, and `goalCompleted`
These fire from the gateway's existing agent-event stream — no agent round-trip and no public
endpoint (they are not reachable over REST by design; only the gateway dispatches them):
@@ -390,6 +403,9 @@ endpoint (they are not reachable over REST by design; only the gateway dispatche
`terminateSession` archive path) in `session-manager.ts`, **awaited with a timeout** so
providers get a bounded window to flush before teardown proceeds. Failures are caught and
logged; archive always proceeds.
+- **`goalCompleted`** — dispatched after team completion succeeds and a once-per-goal/head marker is
+ claimed. The dispatcher is non-blocking with respect to completion semantics: provider errors and
+ timeouts are diagnostics only. Hindsight uses it to retain replaceable outcome digests.
### Per-turn hooks: the provider-bridge extension
@@ -408,29 +424,28 @@ session id and `404` when the session is unknown (neither live nor persisted).
| Method + path | Caller | Behaviour |
|---|---|---|
-| `POST /api/sessions/:id/provider-hooks/before-prompt` | provider-bridge extension | Body `{ prompt?, turn?: { index } }`. Dispatches `beforePrompt`; responds `{ tail, blocks }`. |
+| `POST /api/sessions/:id/provider-hooks/before-prompt` | provider-bridge extension | Body `{ prompt?, turn?: { index } }`. Dispatches `beforePrompt`; responds `{ content, blocks }`. |
| `POST /api/sessions/:id/provider-hooks/before-compact` | provider-bridge extension | Dispatches `beforeCompact` and responds `{}` once provider flushes settle (bounded by per-provider timeouts). |
| `GET /api/sessions/:id/context-trace?limit=N` | inspector / diagnostics | Returns `{ entries }` from the [trace store](#the-trace-store), oldest→newest; `limit` keeps the most recent N (clamped to 1000). |
-**`before-prompt` response shape.** `tail` is the fenced blocks joined inside the
-dynamic-context delimiters, or `""` when no block survived budgeting:
+**`before-prompt` response shape.** `content` is the accepted blocks joined as fenced
+`` envelopes, or `""` when no block survived budgeting:
```
-\n
……
-
```
`blocks` is **metadata-only** — each entry is `{ id, providerId, title, tokenEstimate }`. The
-full block content lives inside the fenced `tail`; the metadata array exists for the inspector
-and for diagnostics without re-sending the body. After dispatch the endpoint also refreshes the
-persisted prompt-sections snapshot **best-effort** (non-fatal: a failure is logged and the
-response still returns) so `GET /api/sessions/:id/prompt-sections` reflects the turn's
-dynamic-context tail.
-
-When no `LifecycleHub` is configured, `before-prompt` returns `{ tail: "", blocks: [] }` and
+full block text lives in `content`; the metadata array exists for the inspector and diagnostics
+without parsing the body. After dispatch the endpoint also refreshes the persisted
+prompt-sections snapshot **best-effort** (non-fatal: a failure is logged and the response still
+returns) so `GET /api/sessions/:id/prompt-sections` shows the turn's latest Dynamic Context
+snapshot even though that context is delivered through a hidden message rather than the system
+prompt.
+
+When no `LifecycleHub` is configured, `before-prompt` returns `{ content: "", blocks: [] }` and
`before-compact` returns `{}` — the turn proceeds unchanged.
## The provider-bridge extension
@@ -445,13 +460,14 @@ hash.
The generated extension subscribes to two pi events:
- **`before_agent_start`** (per turn) → POST `…/provider-hooks/before-prompt` with
- `{ prompt: event.prompt }`, with an `AbortController` timeout of **2500 ms**. On success it
- returns `{ systemPrompt: stripDelimitedTail(event.systemPrompt) + resp.tail }`. On **any**
- failure (transport, timeout/abort, non-2xx, parse error) it returns `undefined` and the turn
- proceeds with the unmodified prompt.
+ `{ prompt: event.prompt }`, with an `AbortController` timeout of **2500 ms**. On success, if the
+ response contains non-empty `content`, it returns a hidden custom message:
+ `{ message: { customType: "bobbit:dynamic-context", content, display: false } }`. On empty
+ content or **any** failure (transport, timeout/abort, non-2xx, parse error), it returns
+ `undefined` and the turn proceeds with the unmodified prompt and system prompt.
- **`session_before_compact`** → POST `…/provider-hooks/before-compact`, with a timeout of
**5000 ms**. The result is ignored (compaction output is not amended here); all failures are
- swallowed.
+ swallowed. This `beforeCompact` behavior is unchanged.
Transport and auth are identical to the tool-guard: read `BOBBIT_GATEWAY_URL` / `BOBBIT_TOKEN`
from the environment, falling back to `/state/{gateway-url,token}`, and
@@ -460,37 +476,31 @@ unchanged".
### The injection invariant (non-negotiable)
-> **The user's message text is NEVER mutated.** Per-turn recall is injected only into the
-> outgoing **system-prompt tail**.
+> **The user's message text is NEVER mutated, and per-turn Dynamic Context is never appended to
+> `systemPrompt`.**
This is a hard correctness boundary, not a style preference. Mutating the user prompt would
corrupt the transcript echo and re-open the comms-stack optimistic-reconciliation **duplicate**
-class. The bridge forwards `event.prompt` to the gateway **read-only** and only ever returns a
-modified `systemPrompt`. A test pins that the user's message text is byte-identical with and
-without the bridge.
-
-pi's `before_agent_start` event exposes both `prompt: string` and `systemPrompt: string`, and
-`BeforeAgentStartEventResult` accepts `systemPrompt?: string`, so the bridge takes the simple,
-verified path: read `event.systemPrompt`, strip any prior delimited tail, append the fresh tail,
-return `{ systemPrompt }`.
+class. Mutating `systemPrompt` on every `beforePrompt` turn would also churn provider prompt
+caches: Anthropic-style caching treats the system prompt as a strict byte prefix, so a changing
+Dynamic Context tail can force repeated cache writes for the full cached system block.
-### Idempotent, delimited tail
+The bridge therefore forwards `event.prompt` to the gateway **read-only** and, when the gateway
+returns non-empty Dynamic Context, returns a hidden pi custom message with
+`customType: "bobbit:dynamic-context"`. pi appends extension messages on the user-side message
+channel, so the model still receives the fenced context for that turn, but `context.systemPrompt`
+stays byte-identical across turns even when the Dynamic Context content changes.
-The dynamic-context region is wrapped in delimiters that **must stay byte-identical** between
-`provider-bridge-extension.ts` and the `before-prompt` endpoint in `server.ts`:
+If the gateway returns empty content, or the callback fails or times out, the bridge returns
+`undefined`. No prompt text, user text, or system prompt bytes are changed.
-```
-
-…fenced context-blocks…
-
-```
+### System-prompt stability
-Each turn the bridge calls `stripDelimitedTail(systemPrompt)` to remove any prior region
-(including a truncated, end-delimiter-less one) and then appends the fresh tail. Applying
-strip-then-append repeatedly yields **exactly one** delimited region — the tail never grows
-turn-over-turn. This idempotency is unit-pinned. The strip helper is duplicated (host-side TS
-export + inlined JS in the generated extension) precisely so the two copies can be kept in sync;
-the delimiters are the contract between them.
+The old per-turn system-prompt tail path is intentionally retired for `beforePrompt`. The only
+Dynamic Context that belongs in `systemPrompt` is `sessionSetup` output, assembled once at spawn
+time. Per-turn blocks are delivered as hidden `bobbit:dynamic-context` custom/user-side messages;
+`beforeCompact` still only notifies providers before compaction and does not amend compaction
+output.
### Activation — generated only when a provider wants it
@@ -557,15 +567,18 @@ ambient context a session received and why blocks were dropped.
`dispatch("sessionSetup", …)` during session setup, rendering kept blocks as the
`PromptParts.dynamicContext` → **Dynamic Context** system-prompt section — see
[Session-setup wiring (G1.3)](#session-setup-wiring-g13).
-- **G1.4 (done)** wires the remaining four hooks: the per-turn `beforePrompt` / `beforeCompact`
+- **G1.4 (done)** wires the per-turn/session hooks: the per-turn `beforePrompt` / `beforeCompact`
via the generated [provider-bridge extension](#the-provider-bridge-extension), and the
server-side `afterTurn` / `sessionShutdown` from the gateway's agent-event stream; plus the
REST surface (`/provider-hooks/*`, `/context-trace`) — see
[Per-turn + lifecycle wiring (G1.4)](#per-turn--lifecycle-wiring-g14).
+- **Goal lifecycle hooks (done)** add `goalProvisioned` for filesystem treatments and
+ `goalCompleted` for non-blocking completion/outcome side effects.
- **G2** ships the first built-in production provider, the [Hindsight memory pack](hindsight-memory.md).
- It is dormant until a Hindsight URL is configured, so out of the box behaviour is unchanged —
- with no active provider, no Dynamic Context section is added and the per-turn bridge is never
- spawned.- Selector hooks (`beforeGoalCreate` / `beforeSessionSpawn`) are a separate, later goal (G8).
+ It declares `defaultDisabled: true` and remains dormant until setup enables/configures it, so
+ out-of-the-box behaviour is unchanged — with no active provider, no Dynamic Context section is
+ added and the per-turn bridge is never spawned (while existing configured installs remain active).
+- Selector hooks (`beforeGoalCreate` / `beforeSessionSpawn`) are a separate, later goal (G8).
## See also
diff --git a/docs/managed-runtimes.md b/docs/managed-runtimes.md
new file mode 100644
index 000000000..cb72a7734
--- /dev/null
+++ b/docs/managed-runtimes.md
@@ -0,0 +1,984 @@
+# Managed runtimes
+
+> This page covers three layers: **P1**, the pure manifest + helper layer
+> (no Docker); **P2**, the Docker-backed supervisor + REST surface that runs
+> the prepared inputs; and **P3**, the deployment-mode / consent / lifecycle
+> wiring that decides *when* a runtime starts and links it to a provider. P1 is
+> documented first; jump to
+> [P2 — the Docker-backed supervisor + REST](#p2--the-docker-backed-supervisor--rest)
+> for lifecycle, routes, and command discipline, or to
+> [P3 — deployment modes, consent & lifecycle](#p3--deployment-modes-consent--lifecycle)
+> for modes, explicit-consent start, disable/uninstall/purge, and `ctx.runtime`
+> injection.
+
+Bobbit packs can ship **managed runtimes**: a declarative description of a
+containerised service stack (images, env, secrets, ports, launch modes) that
+Bobbit prepares and — in a later phase — runs via Docker Compose on the user's
+behalf. The motivating example is the **Hindsight** stack (data-plane API +
+Postgres): a user installs the pack, supplies an LLM API key, and Bobbit brings
+up the whole thing with generated credentials and free host ports, no manual
+`docker compose` wrangling.
+
+This page documents **P1**, which is deliberately scoped to the **pure layer**:
+the manifest schema, the contribution loader, and the helper utilities that
+*prepare* everything a later Docker phase will consume. **Nothing in P1 executes
+Docker.** No `docker` CLI, no Docker API, no compose expansion, no reading of the
+compose file. The only side effects are local filesystem writes (the `.env`
+file) and persisted state through injected stores. This keeps the whole layer
+unit-testable against temp dirs and makes the security-sensitive parsing /
+path-containment logic verifiable in isolation, before any process is spawned.
+
+## Where it fits
+
+A managed runtime is a new **pack contribution kind**, alongside roles, tools,
+skills, entrypoints, panels, and routes (see [marketplace.md](marketplace.md)
+and the [Extension Host authoring guide](extension-host-authoring.md)). A pack
+declares which runtime descriptors it ships in `pack.yaml` under
+`contents.runtimes`, and the descriptors themselves live in `runtimes/*.yaml`
+inside the pack root.
+
+The P1 code splits into three pure seams:
+
+| Concern | Module |
+|---|---|
+| Load `runtimes/.yaml` for the names a pack lists | `src/server/agent/pack-contributions.ts` (`loadRuntimes`, `RuntimeContribution`) |
+| Parse + deep-validate a runtime descriptor (incl. compose-path containment) | `src/server/runtime/manifest.ts` |
+| Pure prep helpers: secrets, `.env`, ports, env resolution, mode invocation | `src/server/runtime/helpers.ts` |
+
+`src/server/runtime/index.ts` is a barrel that re-exports the manifest and
+helper modules. The pack-contribution registry exposes
+`PackContributionRegistry.getRuntime(projectId, packId, runtimeId)` to resolve a
+loaded descriptor.
+
+The Hindsight reference pack lives at `market-packs/hindsight/`:
+
+```
+market-packs/hindsight/
+ pack.yaml # contents.runtimes: [hindsight]
+ runtimes/hindsight.yaml # the runtime descriptor (manifest)
+ runtime/compose.yaml # static, digest-pinned compose template
+```
+
+Note the two similarly named directories are distinct: `runtimes/` (plural)
+holds descriptor manifests listed in `contents.runtimes`; `runtime/` (singular)
+is just where this pack happens to keep its compose template. The descriptor
+points at the compose file with a pack-relative `composeFile` path.
+
+## Pack declaration: `contents.runtimes`
+
+A pack opts into shipping runtimes by listing descriptor basenames (no
+extension) in `pack.yaml`:
+
+```yaml
+name: hindsight
+version: 1.0.0
+contents:
+ roles: []
+ tools: []
+ skills: []
+ entrypoints: []
+ runtimes:
+ - hindsight # loads runtimes/hindsight.yaml (or .yml)
+```
+
+`contents.runtimes` is **optional** and normalized to `[]` when absent, so packs
+that ship no runtimes stay valid (`validateManifest` in
+`src/server/agent/pack-manifest.ts`). When present it must be an array of
+**safe basenames** — the same path-traversal guard (`isSafeBasename`) used for
+`contents.entrypoints`: each entry must match `/^[A-Za-z0-9._-]+$/` with no path
+separators and no `..` segments. This is enforced at manifest-validation time so
+a malicious basename can never reach the filesystem join in the loader.
+
+The type is `PackManifest.contents.runtimes?: string[]` in
+`src/server/agent/pack-types.ts`.
+
+## The contribution loader
+
+`loadRuntimes(packRoot, manifest)` in `src/server/agent/pack-contributions.ts`
+loads `runtimes/.yaml` (or `.yml`) **only** for the basenames listed in
+`contents.runtimes`. It mirrors the existing G1.1 entrypoint loader pattern
+(`loadEntrypoints`) so the two behave identically:
+
+- **Safe-basename + realpath containment before read.** Each `listName` is
+ re-checked with `isSafeBasename` (defense-in-depth even though
+ `validateManifest` already guards it), and the resolved path is asserted to
+ stay within `runtimes/` via `isPackPathWithinRoot`
+ (`src/server/extension-host/path-guard.ts`). A name resolving outside the dir
+ is dropped with a warning rather than read.
+- **Tolerant warn-and-drop** for a missing file, malformed YAML, a non-mapping
+ document, or a missing/invalid `id`. A broken descriptor never aborts the
+ pack's load — it is logged and skipped. This is the same tolerant-loader
+ contract the rest of the contribution system uses, so one bad file can't take
+ down an otherwise-good pack.
+- **Hard error on a duplicate `id` within a pack.** A second descriptor reusing
+ an `id` throws `PackContributionError`, which aborts the pack's load so the
+ registry surfaces a loud conflict rather than silently shadowing.
+
+The loader is **intentionally shallow**: it enforces a valid `id` (matching the
+panel-id shape `/^[a-z0-9][a-z0-9_.-]*$/i`) and intra-pack id uniqueness, then
+carries the **raw parsed YAML** as `RuntimeContribution.manifest`. Deep manifest
+validation — compose-path containment, env / secrets / ports / modes — is the
+**runtime manifest parser's** job (`src/server/runtime/manifest.ts`), applied by
+later orchestration phases. Keeping deep validation out of the loader keeps the
+load phase pure and cheap.
+
+`RuntimeContribution` carries: `id`, optional `title` / `description`, the raw
+`manifest`, the `listName` (the `contents.runtimes` basename — the activation
+key), `sourceFile` (absolute path to the descriptor, the anchor for resolving
+`composeFile`), and `packRoot` (the containment root).
+
+## Runtime manifest schema
+
+A descriptor (`runtimes/.yaml`) parses into a `RuntimeManifest`
+(`src/server/runtime/manifest.ts`). `parseRuntimeManifest(raw, sourceFile,
+packRoot, problems?)` parses YAML then calls `validateRuntimeManifest(...)`.
+
+Validation is **tolerant in the same spirit as the loaders**: problems are
+pushed onto an optional `problems[]` string sink and the parse returns `null`
+for an unusable manifest rather than throwing.
+
+> The YAML below is an **illustrative** schema walkthrough that exercises every
+> field kind (multiple ports, a second generated secret, a `value` ref,
+> `omitServices`). The **actual shipped** `market-packs/hindsight` descriptor is
+> trimmer — see [The Hindsight reference pack](#the-hindsight-reference-pack).
+
+```yaml
+id: hindsight # REQUIRED. /^[a-z0-9][a-z0-9_.-]*$/i
+title: Hindsight # OPTIONAL.
+description: >- # OPTIONAL.
+ Managed Hindsight stack — data-plane API backed by Postgres.
+
+composeFile: ../runtime/compose.yaml # REQUIRED. Pack-relative; see containment below.
+
+# Generated-and-persisted secrets (idempotent). NOT user-supplied.
+secrets:
+ - key: HINDSIGHT_DB_PASSWORD # SecretsStore key. REQUIRED, unique.
+ generate: true # OPTIONAL bool. true ⇒ generated+persisted.
+ - key: HINDSIGHT_API_SECRET
+ generate: true
+ # env: SOME_VAR # OPTIONAL — env var name to expose under.
+
+# Host ports allocated via bind :0, persisted, re-validated on boot.
+ports:
+ - key: HINDSIGHT_WEB_PORT # Persistence key. REQUIRED, unique.
+ container: 3000 # OPTIONAL — informational container-side port (1..65535).
+ # env: SOME_VAR # OPTIONAL — env var to expose chosen port under.
+
+# Base environment shared by all modes. Each value is exactly ONE ref kind.
+env:
+ HINDSIGHT_API_LLM_API_KEY:
+ secret: HINDSIGHT_API_LLM_API_KEY # resolve from a USER-CONFIGURED secret
+ HINDSIGHT_API_SECRET:
+ generate: HINDSIGHT_API_SECRET # resolve from a GENERATED+persisted secret
+ HINDSIGHT_WEB_PORT:
+ port: HINDSIGHT_WEB_PORT # resolve from an allocated host port
+ SOME_LITERAL:
+ value: ${dataDir:-~/.hindsight} # literal with ${var} / ${var:-default} substitution
+
+# Launch modes — mode-specific argument construction.
+modes:
+ managed-postgres:
+ title: Managed Postgres
+ services: [api, web, db] # compose services to bring up
+ # profiles: [...] # OPTIONAL — compose profiles to activate
+ # omitServices: [...] # OPTIONAL — services to exclude from `services`
+ # requireEnv: [...] # OPTIONAL — env names that MUST resolve non-empty
+ env: # OPTIONAL — mode env overlay (merged over manifest.env)
+ HINDSIGHT_API_DATABASE_URL:
+ value: postgres://hindsight:${HINDSIGHT_DB_PASSWORD}@db:5432/hindsight
+```
+
+### Env value refs
+
+An env value is either a **plain string** (treated as a literal with placeholder
+substitution) or a **ref object** that declares **exactly one** of:
+
+| Ref | Resolves from |
+|---|---|
+| `secret: ` | a **user-configured** secret (never generated) — e.g. the LLM API key |
+| `generate: ` | a **generated + persisted** secret of that key (idempotent) |
+| `port: ` | an **allocated host port** (rendered as a string) |
+| `value: ` | a literal, with `${var}` / `${var:-default}` substitution |
+
+Declaring zero or more than one of these is a validation error. Numbers and
+booleans are coerced to strings. Env names must match conventional shell-env
+identifiers (`/^[A-Za-z_][A-Za-z0-9_]*$/`); secret/port keys use a safe key
+token (`/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/`) and must be unique within their list.
+
+### Modes
+
+`modes` is a map of mode id → `RuntimeModeSpec`. A mode selects which compose
+services to run and overlays mode-specific env over the manifest-level `env`:
+
+- `services` — compose services to bring up.
+- `profiles` — compose profiles to activate.
+- `omitServices` — services removed from `services` (lets a mode list the full
+ service set then subtract — e.g. external-postgres lists `db` then omits it).
+- `requireEnv` — env names that **must** resolve to a non-empty value; checked
+ when building the invocation.
+- `env` — a mode-level overlay merged **over** `manifest.env` (mode wins).
+
+## Compose path containment / escape rejection
+
+The single most security-sensitive field is `composeFile`. It is **pack-relative
+and resolved relative to the descriptor's directory** (`sourceFile`), and the
+result must stay inside the pack root. `resolveContainedComposePath(composeFile,
+sourceFile, packRoot)` performs this check **purely and lexically** — it never
+touches the filesystem:
+
+1. Reject anything that is not a safe relative path (`isSafeRelativePath` from
+ `src/server/agent/tool-contributions.ts` — rejects absolute paths and `..`
+ escapes).
+2. Resolve `composeFile` against `dirname(sourceFile)`.
+3. Compute `path.relative(packRoot, resolved)` and reject if it is empty, starts
+ with `..`, or is absolute.
+
+It returns the resolved absolute compose path when contained, else `null`. A
+descriptor whose `composeFile` escapes the pack root fails
+`validateRuntimeManifest` with a recorded problem and parses to `null`.
+
+This is enforced **twice** (defense-in-depth): once at parse/validation time, and
+again at invocation-build time in `buildRuntimeInvocation`, which re-derives the
+absolute compose path and throws if it would escape. A pack can therefore never
+steer Bobbit into reading or composing a file outside its own directory, even via
+a crafted relative path like `../../../etc/...`.
+
+## Pure helper utilities
+
+`src/server/runtime/helpers.ts` holds the prep helpers. All stores are injected
+(an interface with `get`/`set`), so the helpers run against `SecretsStore` in
+production and against in-memory fakes in unit tests.
+
+### Idempotent secret generation
+
+```ts
+generateSecretValue(); // crypto.randomBytes(24).toString("base64url")
+getOrCreateRuntimeSecret(store, key, gen?); // returns existing or generates+persists
+```
+
+`generateSecretValue()` is the canonical generated-secret format used across the
+runtime layer: 24 random bytes encoded as URL-safe base64.
+`getOrCreateRuntimeSecret` is **idempotent**: if a non-empty value already exists
+under `key` it is returned unchanged; otherwise a fresh value is generated,
+persisted via `store.set`, and returned. Repeated calls are stable — this is what
+lets the runtime re-prepare on every boot without rotating credentials. The
+generator is an injectable seam (`SecretGenerator`) so tests can assert exact
+values.
+
+### `.env` rendering (mode 0600)
+
+```ts
+renderRuntimeEnvFile(filePath, env); // sorted keys, mode 0600
+escapeDotenvValue(value); // conservative double-quote escaping
+```
+
+`renderRuntimeEnvFile` writes a dotenv file with **stable, sorted key order**
+(deterministic output) and creates parent directories as needed. Because the
+file contains secrets, it is written with **file mode `0600`** (owner
+read/write only). `writeFileSync`'s `mode` option only applies when the file is
+*created*, so the helper also calls `chmodSync(filePath, 0o600)` afterward to
+correct the mode on a pre-existing file. Values are escaped with
+`escapeDotenvValue`, which always double-quotes and escapes backslash, double
+quote, CR, and LF — so a value can never break out of its line or inject another
+assignment.
+
+### Host-port allocation (bind :0), persistence, revalidation
+
+```ts
+probeFreePort(host?); // bind :0, read the assigned ephemeral port
+isPortAvailable(port, host?); // can this port be bound right now?
+allocateHostPort(store, key, opts?); // persisted-or-allocate
+revalidateHostPort(store, key, opts?); // boot-path alias of allocateHostPort
+```
+
+A free host port is found by **binding `:0`** (`net.createServer().listen(0,
+host)`) and reading back the OS-assigned ephemeral port. `allocateHostPort`
+**persists** the chosen port under `key`: if a valid port is already stored *and*
+is still bindable, it is kept; otherwise a fresh port is probed and persisted.
+The default probe host is `127.0.0.1`.
+
+`revalidateHostPort` is the **boot path's** alias with an identical contract: keep
+the persisted port if it is still valid and available, otherwise allocate and
+persist a new one. The motivation is stability with self-healing — a runtime
+keeps the same host port across restarts (so bookmarks/URLs stay valid), but if
+something else has since claimed that port, it transparently moves to a new one
+rather than failing to bind.
+
+### Placeholder substitution
+
+```ts
+substitutePlaceholders(input, vars?); // ${name} and ${name:-default}
+buildPlaceholderVars(ctx); // exposes ports/secrets/generated/vars by key
+```
+
+`substitutePlaceholders` expands `${name}` and `${name:-default}`. A missing or
+empty var falls back to its `:-default`; with **no** default it resolves to the
+**empty string** — never left as a literal `${...}`. This guarantees unresolved
+values can't leak into the env file, and it lets `requireEnv` detect a missing
+required value (it shows up as empty). `buildPlaceholderVars` builds the var map
+from a `RuntimeResolveContext`, exposing allocated ports, user secrets, and
+generated secrets under their own keys — so a literal `value` can interpolate,
+say, a generated DB password by its secret key
+(`postgres://u:${HINDSIGHT_DB_PASSWORD}@db/...`). Explicit `vars` win on a key
+collision.
+
+### Env resolution + mode invocation
+
+```ts
+resolveRuntimeEnv(manifest, mode, ctx); // merge manifest+mode env, resolve all refs
+buildRuntimeInvocation(manifest, mode, inputs); // data-only, NO Docker
+```
+
+`resolveRuntimeEnv` merges `manifest.env` with the mode overlay (mode wins),
+resolves every ref/placeholder against the `RuntimeResolveContext`, and returns a
+plain `Record` with sorted keys. A `secret`/`generate`/`port` ref
+whose value is missing from the context throws — these are programmer/config
+errors, not tolerant cases.
+
+`buildRuntimeInvocation` produces the **data-only** `RuntimeInvocation` that a
+later Docker phase consumes — and runs **no Docker itself**. It:
+
+1. Re-validates compose-path containment (throws on escape — see above).
+2. Resolves the mode env and enforces `requireEnv` (throws if any required name
+ resolves empty).
+3. Subtracts `omitServices` from `services`, and collects `profiles`.
+
+The result carries `runtimeId`, `mode`, the resolved absolute `composeFile`, the
+`envFile` path, the selected `services`/`profiles`, and the fully resolved
+`env`.
+
+## The Hindsight reference pack
+
+`market-packs/hindsight/` is the first managed-runtime pack. Its compose template
+(`runtime/compose.yaml`) is **static and digest-pinned**
+(`name:tag@sha256:<64 hex>`) for reproducibility, and is **never executed in P1**
+— it is only ever selected from and resolved as a path. The images are the
+**verified upstream coordinates**: the data-plane API
+`ghcr.io/vectorize-io/hindsight` and the pgvector-enabled Postgres
+`pgvector/pgvector:pg18`. Managed Bobbit integration only needs the data-plane
+API (container port **8888**) plus Postgres — there is **no** separate
+web/control-plane container.
+
+The descriptor declares one **generated** secret (`HINDSIGHT_DB_PASSWORD`), one
+**port** (`HINDSIGHT_API_PORT` → container 8888), and wires the LLM API key from a
+**user-configured** secret via `HINDSIGHT_API_LLM_API_KEY: { secret: ... }` —
+the only user-supplied secret; the DB password is generated. It also declares a
+`healthcheck` (`path: /health`, `port: HINDSIGHT_API_PORT`) that the supervisor
+polls over HTTP before reporting the runtime `running`.
+
+### Managed vs external Postgres
+
+The two modes differ only in how the database is provided:
+
+- **`managed-postgres`** — includes the `db` service. The data directory is a
+ host **bind mount** that defaults to `${dataDir:-~/.hindsight}` (the
+ `dataDir` var, or `~/.hindsight` when unset). A leading `~` / `~/` in the
+ resolved value is **expanded to the absolute home directory** by the env
+ resolver (`expandTilde` in `helpers.ts`) before it reaches the env file:
+ Docker Compose does **not** expand `~` in `.env` values or bind-mount sources,
+ so an unexpanded `~/.hindsight` would otherwise create a literal `~`-named
+ directory next to the project. The connection string is built
+ from the **generated** password and the in-compose `db` hostname via a
+ `value` ref:
+ `HINDSIGHT_API_DATABASE_URL: postgres://hindsight:${HINDSIGHT_DB_PASSWORD}@db:5432/hindsight`
+ — the `${HINDSIGHT_DB_PASSWORD}` placeholder resolves from the generated
+ secret of the same key.
+- **`external-postgres`** — lists `db` in `services` but `omitServices: [db]`,
+ so no managed database is started. It declares `requireEnv:
+ [HINDSIGHT_API_DATABASE_URL]` and injects that URL from a **user-configured**
+ `secret` ref. The operator supplies their own Postgres connection string.
+
+Both behaviours are expressed **purely declaratively** in the manifest; the
+helpers contain no Hindsight-specific logic.
+
+## The pure / no-Docker boundary
+
+P1 stops at preparing inputs. To be explicit about what is and isn't in scope:
+
+| In P1 (pure) | Deferred to a later Docker phase |
+|---|---|
+| Parse + validate descriptors | `docker compose up` / process spawning |
+| Compose-path containment checks | Reading / expanding the compose file |
+| Generate + persist secrets | Pulling images |
+| Render the `.env` file (mode 0600) | Mounting the bind volume |
+| Allocate / persist / revalidate host ports | Health checks, lifecycle, teardown |
+| Resolve env + build the data-only `RuntimeInvocation` | Executing the invocation |
+
+The boundary exists so the trust-critical logic (pack-authored YAML parsing,
+path containment, secret handling) is fully unit-testable without Docker, and so
+a bug in that logic is caught long before anything is launched. Unit coverage
+lives in `tests/runtime-manifest.test.ts` and `tests/runtime-helpers.test.ts`.
+
+## P2 — the Docker-backed supervisor + REST
+
+P1 stops at preparing inputs; **P2 runs them**. The `PackRuntimeSupervisor`
+(`src/server/runtimes/pack-runtime-supervisor.ts`) is the **single place** that
+shells out to Docker for managed pack runtimes, and the REST surface in
+`server.ts` exposes its lifecycle to the UI/API. The split exists for the same
+reason as the P1/P2 boundary itself: all trust-critical *preparation* stays pure
+and unit-testable, and the one module that touches a real daemon is small,
+audited, and fully mockable.
+
+`src/server/runtimes/index.ts` is the barrel that re-exports the supervisor and
+its public types/helpers.
+
+### Supervisor lifecycle
+
+The supervisor is constructed once per server with a
+`PackContributionResolver` (to look up active runtimes by project scope) plus
+the injectable seams below. Its surface:
+
+| Method | Docker command | Returns |
+|---|---|---|
+| `list(projectId?)` | one `compose ps` per active runtime | `PackRuntimeStatus[]` |
+| `status(packId, runtimeId, projectId?)` | `compose ps --format json` | `PackRuntimeStatus` |
+| `ensureRuntime(packId, runtimeId, {projectId, mode})` | fast-path `ps`, else `up -d` | `PackRuntimeStatus` |
+| `start(packId, runtimeId, {projectId, mode})` | `compose up -d` + health poll | `PackRuntimeStatus` |
+| `stop(packId, runtimeId, {projectId})` | `compose stop` | `PackRuntimeStatus` |
+| `restart(packId, runtimeId, {projectId, mode})` | `stop` then `start` | `PackRuntimeStatus` |
+| `logs(packId, runtimeId, {projectId, tail})` | `compose logs --tail N` | `string` |
+
+**`ensureRuntime` is idempotent and the intended entry point** for "make sure
+this is up". It first calls `status`; if the runtime is already `running` it
+returns immediately without touching Docker again, and if Docker is unavailable
+it returns the `docker-unavailable` status verbatim rather than falling through
+to a noisier `start`. Otherwise it delegates to a **deduplicated** start.
+
+**Concurrent starts collapse to one `compose up`, keyed by runtime identity.**
+A `_startInFlight` map keyed by the **runtime identity alone**
+(`packId\0runtimeId`) holds the in-flight start promise; later callers for the
+same key await the same promise, and the entry is cleared on settle so a
+subsequent call can retry. This mirrors `sandbox-manager.ts`'s `_ensureInFlight`
+discipline and prevents a burst of UI calls from racing multiple `up -d`
+invocations.
+
+The key deliberately **omits both `projectId` and `mode`**, because a runtime
+identity owns **one** rendered `.env` file and **one** compose project, and
+*neither is project-scoped* (`composeProjectFor`/`_envFilePath` are derived from
+pack + runtime + server suffix only). Including `projectId` would let two starts
+for the same pack/runtime from different project scopes bypass the guard and race
+two `compose up`s clobbering one env file; including `mode` would let two
+different-mode starts each `renderRuntimeEnvFile` to the same path and race.
+Instead the in-flight **mode is tracked alongside the promise**: a concurrent
+start requesting the **same** mode shares the promise (one `compose up`), while a
+concurrent start requesting a **conflicting** mode is **rejected** with
+`PackRuntimeBadRequestError` (rather than silently collapsing onto the first
+mode's promise) so the race is impossible and the caller learns its mode was not
+honoured. Mode-agnostic `ensureRuntime` callers pass the same (usually
+`undefined`) mode and still share one key.
+
+**Idempotent fast-path via a `_started` set.** A per-instance `Set` of runtime
+keys the supervisor has brought up to `running` drives `start`'s fast-path: a
+repeat `start` of a still-running runtime must not re-render the invocation,
+`compose up` again, or rotate its host port. The set is cleared on `stop`/`down`
+(the runtime is no longer up) and is per-instance (not disk-backed) so it never
+leaks across the unit fixtures' shared data dir. After a server restart the flag
+is empty, so the first post-restart `start` falls back to the `reusePersisted`
+port path (no rotation) rather than the in-memory shortcut.
+
+**Startup health polling.** After `up -d`, `start` polls every `pollIntervalMs`
+(default 1 s) until the runtime is ready or `startupTimeoutMs` (default 60 s)
+elapses. Each poll first reads `compose ps`, mapped to a single state by
+`mapServicesToState`:
+
+- any service `unhealthy` → `unhealthy`
+- all services `running` and (no healthcheck **or** `healthy`) → `running`
+- any service `running`/`created`/`restarting`/`starting` → `starting`
+- otherwise → `stopped`
+
+A `docker-unavailable` (ENOENT) or a Docker-reported `unhealthy` short-circuits
+the loop immediately.
+
+**HTTP readiness gate.** When the manifest declares a `healthcheck`
+(`{ service?, port, path }`, where `port` names a declared `ports[].key`),
+`start`/`ensureRuntime` do **not** report `running` off a `compose ps` "running"
+alone — a container is often up well before its HTTP server accepts requests. The
+supervisor additionally polls
+`http://127.0.0.1:` (via an injectable
+`httpProbe` seam, defaulting to a `fetch` GET) and only completes as `running`
+once it returns **HTTP 200**. Runtimes with no declared `healthcheck` keep the
+`compose ps`-only readiness behaviour. `status()` always reflects the
+`compose ps` mapping regardless.
+
+If the timeout is hit while still `starting` (or HTTP health never returns 200),
+the result is `unhealthy` with a `"runtime did not become healthy within ms"`
+message — startup never blocks forever.
+
+### Status states
+
+`PackRuntimeStatus.status` is one of:
+
+| State | Meaning |
+|---|---|
+| `docker-unavailable` | the Docker executable was not found (`ENOENT`) |
+| `stopped` | no services running (or `ps` returned none) |
+| `starting` | services created/running but not yet healthy |
+| `running` | all owned services running and healthy |
+| `unhealthy` | a service reported `unhealthy`, or startup timed out |
+
+The status also carries the descriptor (`id`, `packId`, `runtimeId`,
+`packName?`, `title?`, `description?`), the resolved `composeProject`, the
+selected `mode?`, the parsed `services?`, and an optional human `message`.
+
+### REST routes
+
+Wired in `server.ts::handleApiRoute()`, **admin-bearer only** (gated before the
+route runs). The `:id` path segment is the URL-safe
+`encodePackRuntimeId(packId, runtimeId)` (`encodeURIComponent(packId) + ":" +
+encodeURIComponent(runtimeId)`), which the route reverses via
+`decodePackRuntimeId`.
+
+| Route | Method | Response |
+|---|---|---|
+| `/api/pack-runtimes?projectId=` | GET | `{ runtimes: PackRuntimeStatus[] }` |
+| `/api/pack-runtimes/:id/start` | POST | `PackRuntimeStatus` (after ensure/start) |
+| `/api/pack-runtimes/:id/stop` | POST | `PackRuntimeStatus` (after stop) |
+| `/api/pack-runtimes/:id/restart` | POST | `PackRuntimeStatus` (after restart) |
+| `/api/pack-runtimes/:id/logs?tail=` | GET | `{ logs, status?, message? }` |
+
+- The optional `projectId` query param scopes the runtime lookup to a project.
+- `start`/`restart` accept an **optional** `mode` in the JSON body. An **empty**
+ body is valid (default mode). A non-empty but malformed-JSON body, or a
+ present-but-non-string/empty `mode`, is a client error → **400** (the route
+ never silently treats garbage as `{}` and mutates the default mode). `stop`
+ ignores `mode`.
+- Error mapping: `PackRuntimeNotFoundError` → **404**, `PackRuntimeBadRequestError`
+ (malformed id/mode/tail, invalid manifest, failed invocation) → **400**, other
+ errors → **500**. When the supervisor failed to construct at boot, every route
+ answers **503** (`"pack runtime supervisor unavailable"`).
+- The GET-list and mutation routes always re-derive the returned `id` from
+ `{packId, runtimeId}` so it round-trips cleanly through `decodePackRuntimeId`.
+
+**Docker-unavailable behavior.** Status-returning methods (`list`, `status`,
+`ensureRuntime`, `start`, `stop`) translate an `ENOENT` from the executor into a
+`docker-unavailable` status — they never throw for a missing Docker install. The
+`logs` method is the exception: it returns a raw string, so it throws
+`PackRuntimeDockerUnavailableError` on `ENOENT`. The logs route catches that and
+answers a **200** with a consistent shape `{ logs: "", status:
+"docker-unavailable", message }` rather than hiding the missing install behind an
+empty body or a generic 500. `tail` is validated/clamped by the supervisor
+(`clampTail`): non-numeric → 400, otherwise clamped to `[1, 5000]`, default 200.
+
+### Docker Compose command discipline
+
+Every Docker invocation goes through one private `_exec` seam, and the
+discipline is uniform:
+
+- **`execFile`, never a shell string.** Args are passed as an array via the
+ injectable `DockerExecutor` (defaults to a promisified `execFile`), so
+ pack-authored values can never be interpreted by a shell. This matches the
+ project-wide sandbox `execFile` discipline.
+- **`DOCKER_BIN` override.** The executable defaults to
+ `process.env.DOCKER_BIN || "docker"`, so a non-standard Docker location can be
+ configured without code changes.
+- **MSYS env neutralization.** Each call sets `MSYS_NO_PATHCONV=1` and
+ `MSYS2_ARG_CONV_EXCL=*` in the child env so Git-Bash/MSYS on Windows does not
+ rewrite `:`-bearing arguments (compose project names, ports, paths) into
+ mangled Windows paths.
+- **Compose project name + collision guard.** Every command carries `-p
+ bobbit-pack--` (`composeProjectFor`). The
+ per-server `serverIdentitySuffix` (sanitized, or a random 4-byte hex when
+ unset) is appended so two gateways — or two servers sharing a host — never
+ collide on the same compose project for the same pack (design §15.5). Tokens
+ are sanitized to `[a-z0-9_-]` and length-capped by `sanitizeComposeToken`.
+- **Compose file + env file on every call.** Commands always pass `-f
+ --env-file `, not just `up`. Both are derived from the
+ **validated** `RuntimeInvocation`, so `status`/`stop`/`logs` inspect/control
+ the exact same compose context `start` used — regardless of the gateway's
+ current working directory.
+- **Per-runtime service scoping.** `ps`/`stop`/`logs` are scoped to the
+ services this runtime owns (the union of every mode's `services`, from
+ `_servicesForManifest`), so sibling runtimes that share one pack-scoped compose
+ project can never read, stop, or have their health reflected by another
+ runtime. The empty (project-wide) service list is reserved **exclusively** for
+ a successfully validated manifest that genuinely declares no services — a
+ manifest validation/invocation failure propagates (→ 400/500) instead of
+ silently degrading to an unscoped whole-pack command.
+
+**Read/control paths reuse persisted ports verbatim.** When building the compose
+context for `status`/`stop`/`logs`, the supervisor resolves env with
+`reusePersisted: true`: the stored host port is used **without** a bindability
+probe. While a runtime is running its ports are bound, so a revalidating
+allocation would find them un-bindable, rotate to fresh ports, and rewrite
+`ports.json` + the env file — desyncing the persisted port from the live
+container and breaking the next restart.
+
+### Runtime state persistence
+
+Production state lives under `/state/pack-runtimes/`:
+
+- **Rendered `.env` files**, one per compose project, at
+ `//.env` (mode 0600 — see P1's
+ `renderRuntimeEnvFile`).
+- **`ports.json`** — the file-backed `FilePortStore` of allocated host ports.
+ Read/write errors are swallowed (best-effort), so a corrupt file degrades to
+ fresh allocation rather than crashing the supervisor.
+- **Generated secrets** persist through the production `SecretsStore`.
+
+**Pack/runtime-namespaced persistence keys.** Generated secrets and allocated
+ports persist under `packRuntimePersistKey(packId, runtimeId, rawKey)` =
+`pack-runtime:::`. Two unrelated runtimes that both
+declare, say, `PORT` or `DB_PASSWORD` would otherwise collide on the raw
+manifest key in the shared global store and overwrite each other's value. The
+**raw** manifest key is still used for the rendered env-var name and for reading
+**user-configured** secrets (intentionally global/shared — a user configures one
+LLM key once across runtimes); only the persisted storage slot for
+auto-generated secrets and allocated ports is namespaced.
+
+**Archive allowlist.** `state/pack-runtimes/` is listed in
+`GATEWAY_OWNED_FILES` (`src/server/agent/bobbit-archive.ts`). When a user's
+chosen project root happens to be the gateway's own working directory, the
+archiver skips this subtree because it holds the rendered env files and
+`ports.json` for **live** Docker runtimes — archiving it out from under a running
+container would desync the persisted ports/secrets from the live stack.
+
+### Testing & mocking
+
+**Docker is never executed in automated tests** — only in manual integration.
+Two seams make this enforceable:
+
+- **Injectable executor.** `PackRuntimeSupervisorOptions.executor` replaces the
+ real `execFile` with a mock that returns canned `{ stdout, stderr }` (or throws
+ an `ENOENT`-shaped error) per command, so unit tests drive every status walk
+ without a daemon. `now`/`sleep` are also injectable for deterministic timeout
+ tests, and `serverIdentitySuffix` is injected so the compose project name is
+ predictable.
+- **Supervisor factory seam.** `registerPackRuntimeSupervisorFactory(factory)`
+ in `server.ts` lets API E2E tests inject a fully-mocked supervisor so the
+ `/api/pack-runtimes/*` routes can be exercised end-to-end with no Docker. The
+ factory is consulted fresh per request (never cached), so passing `null`
+ immediately reverts to the production instance — no stale mock leaks across
+ in-process E2E tests.
+
+Coverage:
+
+- `tests/pack-runtime-supervisor.test.ts` (unit) — status walk (empty → stopped,
+ running/healthy → running, unhealthy → unhealthy), health timeout → unhealthy,
+ `ENOENT` → docker-unavailable (never throws), concurrent `ensureRuntime` → one
+ `compose up`, `stop` → `compose stop`, compose project name contains the
+ injected suffix, MSYS env on the exec, plus id encode/decode, tail clamp, ps
+ parse, and up-invocation arg/env-file shape.
+- `tests/e2e/pack-runtimes-api.spec.ts` (API E2E) — the REST surface against the
+ injected fake supervisor: list with round-trippable ids, start/stop/restart,
+ logs with tail clamping, and the 400/404 error mappings.
+
+## P3 — deployment modes, consent & lifecycle
+
+P1 prepares inputs and P2 runs them, but neither answers two product questions:
+*when* may Bobbit start a container on the user's machine, and *how* does the
+memory provider reach the managed stack once it is up? **P3 wires the runtime to
+the Hindsight memory provider, gates every start behind explicit user consent,
+and defines the disable / uninstall / purge semantics.** It is the layer that
+turns the raw supervisor into a safe, opt-in managed deployment.
+
+The governing principle is **explicit consent**: an installed or enabled pack
+must *never* spin up Docker on its own — not on boot, install, update, or any
+read. A container starts only from a deliberate user action. This matters
+because a managed runtime pulls images, binds host ports, and writes to a host
+data directory; doing any of that implicitly (e.g. just because a first-party
+pack ships enabled by default) would be a surprising, resource-consuming side
+effect the user never asked for.
+
+### Deployment modes
+
+The Hindsight memory provider exposes a single `mode` config field
+(`market-packs/hindsight/providers/memory.yaml`) with three **deployment
+modes**. These are distinct from the runtime manifest's **launch modes**
+(`managed-postgres` / `external-postgres`); P3 maps one onto the other so the
+provider config is the single user-facing knob.
+
+| Deployment mode (`config.mode`) | What Bobbit runs | Runtime launch mode | Docker? |
+|---|---|---|---|
+| `external` (default) | nothing — points at a Hindsight you already host | — | No |
+| `managed` | Hindsight API + web **+ Postgres** | `managed-postgres` | Yes |
+| `managed-external-postgres` | Hindsight API + web only; DB is yours | `external-postgres` | Yes |
+
+- **`external`** — the unchanged, no-Docker data-plane path documented in
+ [hindsight-memory.md](hindsight-memory.md). The provider talks directly to
+ `externalUrl` with an optional `apiKey`, `namespace`, and `bank`. No runtime is
+ started; all managed side effects are skipped.
+- **`managed`** — Bobbit brings up the whole stack (`api`, `web`, `db`) with a
+ generated Postgres password and a host **bind mount** for the data dir
+ (default `~/.hindsight`, overridable via `dataDir`).
+- **`managed-external-postgres`** — Bobbit runs only `api` + `web` (the
+ `external-postgres` launch mode omits `db`) and injects the operator-supplied
+ `HINDSIGHT_API_DATABASE_URL` from the `externalDatabaseUrl` config secret.
+
+The deployment-mode → launch-mode mapping lives in `resolveRuntimeStartPlan`
+(`server.ts`), which also decides whether a start is even attempted:
+
+```text
+external → { start: false } # no Docker
+managed → { start: true, mode: managed-postgres }
+managed-external-postgres → { start: true, mode: external-postgres }
+```
+
+`start: false` for `external` is what makes the external path a pure *setup*
+flow: the **start** path and the consent card branch on it to avoid forcing a
+managed compose invocation that an external setup never configured. (The
+*teardown* path no longer branches on it — see
+[Disable / uninstall / purge](#disable--uninstall--purge-semantics) — so a
+managed→external reconfiguration can never leak containers.)
+
+### Deployment-surface classification (raw vs activation-filtered contributions)
+
+Deciding *which* deployment surface a pack exposes — and therefore which mode to
+disclose/start — must read the pack's **raw, activation-unfiltered**
+contributions, not the runtime-filtered ones. The capabilities / start / restart
+REST routes (and the marketplace activation path) build their `deploymentConfig`
++ `hasDeploymentSurface` from `PackContributionRegistry.getRawPack(projectId,
+packId)`, which returns the winning pack's contributions **without** dropping
+config-gated providers.
+
+**Why raw?** Hindsight's pack ships `defaultDisabled: true`, and its memory provider also stays
+inactive until config/runtime gates pass. The activation-filtered `getPack` can therefore omit the
+provider on a fresh/default install, which would misclassify Hindsight as a *provider-less* pack and
+disclose/start the Docker default mode
+instead of the **external (no-Docker) setup path**. Reading the raw pack keeps
+the dormant provider visible so a fresh Hindsight is correctly classified as
+`external`.
+
+A provider "carries a deployment mode" (`providerCarriesDeploymentMode`) when its
+effective config has a non-empty `mode` **or** its `activation.activeWhenConfig`
+declares a `mode` key. `hasDeploymentSurface` is the OR of that across the pack's
+raw providers.
+
+**No-deployment-surface fallback.** A pack with **no** provider — or whose only
+provider carries no deployment mode (a runtime-only pack) — has no
+external/managed concept, so `resolveRuntimeStartPlan({})` defaulting to
+`external`/no-start would wrongly suppress its `on-enable` runtime. Both the REST
+start path and marketplace activation apply the same fallback: when
+`!hasDeploymentSurface`, the `on-enable` runtime starts in the **runtime's
+default mode** rather than being gated by the external-default plan. This keeps
+activation and `/api/pack-runtimes/:id/start` from diverging.
+
+**Availability filtering stays activation-aware.** Raw contributions are used
+*only* to classify the deployment surface. Actual runtime *availability* — a
+runtime explicitly disabled via `pack_activation` (`DisabledRefs.runtimes`) — is
+still enforced by the supervisor's **activation-filtered** registry lookups
+(`getPack`/`getRuntime`), so a disabled runtime is absent from `list`/`status`
+and cannot be started. See
+[marketplace.md → activation controls](marketplace.md#activation-controls).
+
+### Activation policy — `startPolicy: on-enable`
+
+A runtime descriptor declares **when enabling it is allowed to start it** via
+`startPolicy` (parsed by `readRuntimeStartPolicy` in
+`src/server/runtimes/pack-runtime-supervisor.ts`):
+
+- **`manual`** (the default for any descriptor that omits the field, and the
+ only value a non-literal/garbage value resolves to) — the runtime *never*
+ starts implicitly. A user must call `POST /api/pack-runtimes/:id/start`.
+- **`on-enable`** — flipping the pack's marketplace activation toggle from
+ **disabled → enabled** counts as the explicit user start. This is the **only**
+ implicit-start trigger, and it still fires only for a managed deployment mode
+ (`plan.start === true`); an `external` enable starts nothing.
+
+The shipped Hindsight runtime sets `startPolicy: on-enable`. Crucially, boot,
+built-in pack discovery, install, update, `list`, and `status` must **never**
+`compose up`. This is pinned, not just asserted in prose:
+`tests/pack-runtime-supervisor.test.ts` ("no-auto-start (P3)") proves `status()`
+and `list()` issue zero `compose up`, and
+`tests/e2e/marketplace-runtime-activation.spec.ts` proves reads (GET
+pack-activation / GET pack-runtimes) and an `external`-mode enable issue no
+start.
+
+### `ctx.runtime` injection — linking the provider to the managed stack
+
+A provider links to a runtime descriptor via the `runtime:` key in its YAML
+(`providers/memory.yaml` → `runtime: hindsight`). For a **managed** deployment
+mode the host injects a `ctx.runtime` object into every provider hook so the
+provider knows where the managed API is listening:
+
+```ts
+ctx.runtime = {
+ baseUrl: "http://127.0.0.1:", // loopback only
+ headers: { Authorization: "Bearer " } | {},
+ status: "running" | "starting" | "stopped" | "unhealthy" | "docker-unavailable",
+}
+```
+
+This is resolved by the `runtimeResolver` wired into the `LifecycleHub`
+(`server.ts`). It is **read-only with respect to Docker**: it calls the
+supervisor's `status` and the **pure** `capabilitySummary` to read the
+already-persisted API host port, and **never starts a container**. The resolver
+returns `undefined` when the mode is `external`, when no supervisor is present,
+or when the API port is not yet known — so a provider can ask for memory before
+the stack is up without triggering a start.
+
+Activation linkage closes the loop after the pack is enabled/configured. External mode activates
+the provider via the `requiresConfig: [externalUrl]` gate; the two managed modes activate it via the
+`activeWhenConfig: { mode: [managed, managed-external-postgres] }` escape hatch, which bridges the provider regardless
+of `externalUrl`. The provider's own `isActive(cfg, ctx.runtime)` gate
+(`market-packs/hindsight/src/shared.ts`) then keeps every hook dormant — no
+client, no network — until `ctx.runtime.status` reports the managed stack is
+actually `running`. The provider **never** starts Docker itself.
+
+### Disable / uninstall / purge semantics
+
+The three teardown verbs differ deliberately in how much they remove, because a
+user's accrued memory lives in a host bind mount that must survive routine
+operations:
+
+| Action | Docker command | Bind-mounted data | Supervisor state (env/ports/secrets) |
+|---|---|---|---|
+| **Disable** (enabled → disabled toggle) | `compose stop` | Kept | Kept |
+| **Uninstall** (`down`, default) | `compose down` | **Kept** (bind mount is outside compose volumes) | Kept |
+| **Purge** (`down -v` + `removeState`) | `compose down -v` | **Kept** (bind mount is not a compose volume) | **Removed** |
+
+- **Disable** stops the containers but leaves the compose project, networks,
+ anonymous volumes, and all state in place, so re-enabling is fast and lossless.
+- **Uninstall** removes the project's containers + networks but **not** named or
+ anonymous compose volumes, and the bind-mounted Postgres data dir lives
+ *outside* compose-managed volumes entirely — so an uninstall → reinstall keeps
+ the user's memory.
+- **Purge** (`POST /api/marketplace/purge-runtime`, or the supervisor's
+ `down({ volumes: true, removeState: true })`) is the explicit destructive
+ path: `compose down -v` plus deletion of the supervisor's own bookkeeping (the
+ rendered `.env`, persisted generated secrets, and allocated host ports,
+ all namespaced by `packRuntimePersistKey`). Even purge **never** deletes the
+ bind-mounted data dir — that is the user's data, removed only by them.
+
+**Teardown is unconditional — it never gates on the current saved deployment
+mode.** Disable and uninstall call `stop` / `down` for **every** runtime
+contribution regardless of the pack's current `config.mode`. This is a
+deliberate **leak guard**: a runtime started in a managed mode and *later*
+reconfigured to `external` would otherwise — if teardown branched on
+`resolveRuntimeStartPlan(currentConfig).start` — skip teardown and leak its
+still-running containers. The teardown verbs are safe to call unconditionally
+because `stop` / `down` are **read-only / minimal / idempotent**: they never
+resolve start-only inputs (e.g. `HINDSIGHT_API_LLM_API_KEY`), reuse an
+already-rendered `.env` only when one exists, and map a missing Docker install to
+a `docker-unavailable` *status* rather than throwing. So calling them for an
+external-only, never-started runtime is a harmless no-op (`compose down`/`stop` on
+an absent project exits 0). Note `resolveRuntimeStartPlan`'s `start: false` for
+`external` still governs *starting* (an external setup never triggers a managed
+`compose up`); only the *teardown* path was decoupled from it.
+
+**Side effects run before activation is persisted.** In the marketplace
+activation `PUT`, the Docker start/stop runs *before* the new activation state is
+written, and a side effect that matters aborts the whole request without
+persisting — so Bobbit never records "enabled" while the container failed to come
+up, or "disabled" while a `stop` threw. A graceful `docker-unavailable` status is
+tolerated (nothing to start/stop on a Docker-less host; the provider is
+defensive), so it persists and is reported rather than treated as a hard failure.
+These branches are pinned across `tests/e2e/marketplace-runtime-activation.spec.ts`
+(enable-failure → 502 + no persist, stop-failure → 502, docker-unavailable
+tolerated, **disable/uninstall tear down unconditionally** — including a runtime
+started managed then reconfigured to `external` — managed uninstall tears down
+without volumes, purge runs `down -v` + state removal).
+
+### Secrets & config mapping
+
+Managed modes pull two extra fields off the provider config and map them onto the
+runtime's env (`resolveRuntimeStartPlan`):
+
+| Provider config key | Runtime env | Notes |
+|---|---|---|
+| `llmApiKey` | `HINDSIGHT_API_LLM_API_KEY` | The managed API's LLM key — the runtime's *only* user-configured secret. Required to start a managed mode; never used by the provider client itself. A value set directly in the global secret store under the same key also works (the direct value wins). |
+| `externalDatabaseUrl` | `HINDSIGHT_API_DATABASE_URL` | Operator Postgres URL for `managed-external-postgres`; unused in `external`/`managed`. |
+
+Both, plus `apiKey`, are **secrets** and are redacted on the `config` GET surface
+(`redactConfig` in `market-packs/hindsight/src/shared.ts`): the raw value is never
+echoed and collapses to a boolean — `apiKeySet`, `externalDatabaseUrlSet`,
+`llmApiKeySet`. The managed Postgres password (`HINDSIGHT_DB_PASSWORD`) is
+**generated and persisted** by the supervisor, never user-supplied.
+
+The **data directory** (`dataDir`, default `~/.hindsight`) and the **allocated
+host ports** are *disclosed*, not secret — they are surfaced on the consent card
+below so the user sees exactly what will be bound and where data will be written.
+
+### Enable-card capability summary (consent disclosure, §8)
+
+Before the user consents to a start, the Market UI renders a **capability
+summary** from `GET /api/pack-runtimes/:id/capabilities` (the supervisor's
+`capabilitySummary`). It is **pure** — derived only from the validated manifest,
+the selected/effective deployment mode, and any *already-persisted* host ports —
+so it **never starts Docker and never allocates new ports or secrets**, making it
+safe to render pre-consent. It discloses, per design §8:
+
+- **Images / services** brought up for the selected mode (after `omitServices`).
+- **Host ports** that will be bound (with the persisted host port when known, or
+ "allocated on enable" otherwise — never a loopback URL on the container port).
+- **Volume path** — the effective managed-Postgres bind-mount path, reflecting a
+ custom `dataDir` exactly as activation will mount it (not a schema default).
+- **Start policy** (`on-enable` vs `manual`) and the **memory/trust disclosure**
+ copy (a first-party trust note explaining what is stored and that disable keeps
+ data while purge removes volumes + state).
+
+For **external** mode the route returns the descriptor/trust copy but **no**
+services/ports/volume and `dockerRequired: false`, so the UI shows no-Docker
+setup guidance instead of a Docker start disclosure.
+
+**The disclosure is refetched on every marketplace (re)load.** The Market UI
+caches the capability disclosure per `scope`/`packId`/`runtimeId`/`projectId` —
+but that key cannot encode the pack's current config revision, so a stale
+`external` disclosure could be shown right before an enable after the user
+switched the mode to `managed`. To prevent disclosing the wrong mode at the
+moment of consent, `invalidateRuntimeCapabilities()` (`src/app/marketplace-page.ts`)
+drops the whole disclosure cache on every marketplace view (re)load, so the
+consent card always refetches against current server config before the enable
+toggle. Coverage: `tests/marketplace-runtime-consent.spec.ts` (managed discloses
+services/ports/volume/trust; external shows setup guidance; missing summary falls
+back to static copy; the UI refetch drops a stale disclosure; master-toggle
+counts include the `runtimes` array).
+
+### `updatePack` and data survival
+
+Updating a managed pack must not cost the user their memory. `updatePack` swaps
+only the pack **contents** dir (atomic rename: stage → swap → drop), never the
+bind-mounted Postgres data dir nor the supervisor's runtime-state dir. So across
+a disable → `updatePack` → re-enable cycle the persisted host port, generated
+secrets, and Postgres data all survive, and a re-enable reuses them. The
+supervisor state subtree (`state/pack-runtimes/`) is additionally on the
+`GATEWAY_OWNED_FILES` archive allowlist (see P2) so the archiver never moves live
+runtime bookkeeping out from under a running container.
+
+### Manual Docker integration test
+
+Automated tests never touch Docker — `ctx.runtime` injection, the no-auto-start
+invariant, mode mapping, and consent disclosure are all driven against mocks. The
+full managed lifecycle against **real Docker** is verified manually by
+`tests/manual-integration/hindsight-runtime.test.ts`. It drives the real
+supervisor over the shipped manifest/compose through:
+
+```text
+enable (compose up, managed-postgres) → wait healthy → retain/recall round-trip
+ → disable (compose stop) → bind data survives → updatePack → state + data survive
+ → re-enable → recall still finds the marker
+```
+
+It isolates everything under a per-run temp dir (rendered env, generated secrets,
+allocated ports, the Postgres bind dir) with a unique bank/namespace/marker, and
+tears down (`down -v` + `rm`) in `finally` — so it never touches the user's
+`~/.hindsight` or the production `bobbit` bank. It **skips cleanly** (never fails)
+when Docker is unavailable, when `HINDSIGHT_API_LLM_API_KEY` is unset, or when the
+digest-pinned images are not pullable — keeping the manual suite green everywhere
+while doing real work only where a managed Hindsight can actually start.
+
+```bash
+HINDSIGHT_API_LLM_API_KEY= npm run build \
+ && node --import tsx --test tests/manual-integration/hindsight-runtime.test.ts
+```
+
+### P3 REST surface
+
+Beyond the P2 routes above, P3 adds (admin-bearer only):
+
+| Route | Method | Purpose |
+|---|---|---|
+| `/api/pack-runtimes/:id/capabilities?projectId=&mode=` | GET | Pure pre-start consent disclosure. Deployment-mode aware; `external` ⇒ `dockerRequired: false` + empty services/ports. |
+| `/api/pack-runtimes/:id/down` | POST | `compose down`. Body `{ volumes?, removeState? }`: default preserves bind data (uninstall primitive); `volumes:true + removeState:true` is purge. |
+| `/api/marketplace/purge-runtime` | POST | `{ packName, scope, runtimeId, projectId? }` → `down -v` + state removal. |
+| `PUT /api/marketplace/pack-activation` | PUT | Toggling an `on-enable` runtime's pack disabled→enabled starts it (managed modes only); enabled→disabled stops it. Side effects run before persistence; a real failure → **502** with the prior activation so the UI reverts the toggle. |
+
+## Related
+
+- [marketplace.md](marketplace.md) — packs, `contents`, activation, precedence.
+- [hindsight-memory.md](hindsight-memory.md) — the memory provider this runtime
+ backs: deployment modes, config keys, dormancy, and the provider lifecycle.
+- [lifecycle-hub.md](lifecycle-hub.md) — the hub that injects `ctx.runtime` and
+ dispatches the provider hooks.
+- [extension-host-authoring.md](extension-host-authoring.md) — the sibling
+ contribution kinds (entrypoints, panels, routes) whose loader patterns the
+ runtime loader follows.
diff --git a/docs/marketplace.md b/docs/marketplace.md
index e2a9189bb..d8c464fb7 100644
--- a/docs/marketplace.md
+++ b/docs/marketplace.md
@@ -140,10 +140,10 @@ user-facing entities are toggleable:** roles, tools, skills, and entrypoints. Su
> kinds — `providers` plus the reserved siblings `hooks` / `mcp` / `piExtensions` / `runtimes`
> / `workflows`. They are first-class in `DisabledRefs` and `ACTIVATION_KINDS`, and the
> `pack-activation` catalogue includes their arrays only for schema-2 packs, so the toggles round-trip through the same
-> REST without changing schema-1 catalogue shapes. Of these, only **providers** currently has a loader, so disabling a provider actually
-> removes it from `PackContributionRegistry.listProviders(...)`; the other five toggle purely as
-> catalogue metadata until their loaders land. See
-> [pack.yaml schema 2 → Per-provider activation](#per-provider-activation).
+> REST without changing schema-1 catalogue shapes. Of these, **providers** and **runtimes** currently have loaders: disabling a provider
+> removes it from `PackContributionRegistry.listProviders(...)`, and disabling a runtime removes its descriptor from
+> `PackContributionRegistry.getRuntime(...)`. The other four toggle purely as catalogue metadata until their loaders land. See
+> [pack.yaml schema 2 → Per-provider activation](#per-provider-activation) and [managed runtimes](managed-runtimes.md).
What disabling does:
@@ -247,7 +247,7 @@ Built-in packs are **resolved in place**, never copied into a scope's `market-pa
builtin-defaults < built-in-first-party < server-installed < global-user < project
```
-This was chosen over a copy-install model (auto-copy each pack into a scope on startup) because copy-install fights the user: re-installing a pack the user removed would need a persisted "opted-out" ledger, plus bespoke update-on-upgrade logic to refresh stale copies. With resolve-in-place, **"installed" just means present + active by default**, the only opt-out is *disable* (below), updates ride the app upgrade for free, and the user's `market-packs/` dirs stay purely user-owned. See [the design doc §2](design/built-in-first-party-packs.md) for the rejected alternative in full.
+This was chosen over a copy-install model (auto-copy each pack into a scope on startup) because copy-install fights the user: re-installing a pack the user removed would need a persisted "opted-out" ledger, plus bespoke update-on-upgrade logic to refresh stale copies. With resolve-in-place, **"installed" means present in the built-in band**; most built-in packs are active by default, while packs that declare `defaultDisabled: true` resolve with all contributions disabled until explicitly enabled or already configured. Updates ride the app upgrade for free, and the user's `market-packs/` dirs stay purely user-owned. See [the design doc §2](design/built-in-first-party-packs.md) for the rejected alternative in full.
Because the shipped dir contains a literal `market-packs` path segment, the security-critical pack-identity derivation (`derivePackId` / `packIdFromRoot` / `isMarketPackBaseDir`) yields a stable, correct `packId` with **zero changes to the identity code** — a built-in pack's identity is its dir name exactly like any market pack.
@@ -260,7 +260,7 @@ The Market **Sources** tab shows a distinct, labelled **Built-in** section. It i
### Disabling a shipped feature
-Built-in packs appear in the **Installed** tab in their own *Built-in (shipped)* group, flagged `builtin: true`, with **enable/disable toggles only — no Uninstall and no Update**. Disabling reuses the [#734 activation-override system](#activation-controls) verbatim: it writes `pack_activation` under the **`server` scope** (a shipped feature is a server-wide admin decision, so disabling applies across projects) and removes exactly the toggled user-facing entries (roles/tools/skills/entrypoints) from resolution. Panels and routes stay as support surfaces for whatever remains enabled. Because the migrated feature's old built-in code is **deleted** (the pack is the sole provider), disabling its pack makes the feature genuinely unavailable — the deep-link degrades to an empty "feature unavailable" state, never a crash. Toggling invalidates resolver caches synchronously, so the change takes effect with no restart/reload, and the disabled state **persists across reload and restart** (it lives in server config).
+Built-in packs appear in the **Installed** tab in their own *Built-in (shipped)* group, flagged `builtin: true`, with **enable/disable toggles only — no Uninstall and no Update**. Disabling reuses the [#734 activation-override system](#activation-controls) verbatim: it writes `pack_activation` under the **`server` scope** (a shipped feature is a server-wide admin decision, so disabling applies across projects) and removes exactly the toggled entries from resolution. For default-disabled built-ins, the manifest flag (`defaultDisabled: true`) produces the same all-contributions-disabled overlay on fresh unconfigured installs; an explicit enable marker or already-configured provider config suppresses that overlay so setup and existing live installs keep working. Because migrated built-in code is deleted (the pack is the sole provider), disabling its pack makes the feature genuinely unavailable — deep links degrade to an empty "feature unavailable" state, never a crash. Toggling invalidates resolver caches synchronously, so the change takes effect with no restart/reload, and the state **persists across reload and restart** (it lives in server config).
### Same-name user override (shadowing)
@@ -320,14 +320,17 @@ Schema 2 is the **Extension Platform** workstream's manifest tier. It began as a
new contribution type (**providers**) — and remains **fully back-compatible**: existing
schema-1 (v1) packs see zero behaviour change. A pack opts in with a top-level `schema:` field;
absent (or `1`) keeps the exact v1 semantics.
+
**Providers now dispatch through the Lifecycle Hub.** What began as a manifest-only step is
live: G1.3 wires the `sessionSetup` hook and G1.4 wires the per-turn `beforePrompt` /
`beforeCompact` (via a generated provider-bridge pi extension) plus the server-side `afterTurn`
-/ `sessionShutdown` hooks. An installed + active + enabled provider that declares a hook
-contributes ambient context at that moment — see [docs/lifecycle-hub.md](lifecycle-hub.md). The
-first built-in production provider is the [Hindsight memory pack](hindsight-memory.md) (G2); it
-ships in the built-in band but stays **dormant until a Hindsight URL is configured**, so an
-out-of-the-box install still contributes nothing until you opt in or add another provider pack.
+/ `sessionShutdown` hooks. Goal lifecycle hooks (`goalProvisioned`, `goalCompleted`) run
+server-side for provisioning and completion side effects. An installed + active + enabled provider
+that declares a hook contributes at that moment — see [docs/lifecycle-hub.md](lifecycle-hub.md).
+The first built-in production provider is the [Hindsight memory pack](hindsight-memory.md) (G2); it
+ships with `defaultDisabled: true`, so a fresh install contributes nothing until setup enables it
+(or existing persisted Hindsight config preserves activation).
+
Why ship the schema ahead of the runtime? The Extension Platform landed as a sequence of
independently-mergeable PRs. Defining the manifest surface and the per-entity activation
plumbing first meant later PRs (the lifecycle hub that actually *runs* providers, plus loaders
@@ -370,15 +373,10 @@ each defaults to `[]` when absent:
| `hooks` | `hooks` | No (reserved) | Hook contribution basenames. |
| `mcp` | `mcp` | No (reserved) | MCP contribution basenames (accepted at schema 2 only). |
| `piExtensions` | `pi-extensions` | No (reserved) | PI-extension basenames. Note the YAML key is **`pi-extensions`** (kebab-case) but the parsed field is `piExtensions` (camelCase). |
-| `runtimes` | `runtimes` | No (reserved) | Runtime contribution basenames. |
+| `runtimes` | `runtimes` | **Yes** | Runtime contribution basenames; see [managed runtimes](managed-runtimes.md). |
| `workflows` | `workflows` | No (reserved) | Workflow contribution basenames. |
-**Only `providers` has a loader in this PR.** The other five keys are **accepted** in the
-manifest, **normalised** onto `contents`, and **surfaced in the activation catalogue** (so the
-Market UI and the `pack-activation` REST already see and toggle them), but there is no loader
-that reads their files — that is owned by the later goals that implement each contribution
-type. Declaring them today is harmless: they validate as basenames and round-trip, nothing
-more.
+**Providers and runtimes have loaders.** `providers/.yaml` files activate lifecycle hooks, while `runtimes/.yaml` files declare pure managed-runtime descriptors (see [managed runtimes](managed-runtimes.md)). The other four keys are **accepted** in the manifest, **normalised** onto `contents`, and **surfaced in the activation catalogue** (so the Market UI and the `pack-activation` REST already see and toggle them), but there is no loader that reads their files — that is owned by the later goals that implement each contribution type. Declaring them today is harmless: they validate as basenames and round-trip, nothing more.
#### Minimal schema-2 example
@@ -395,8 +393,9 @@ contents:
tools: []
skills: []
providers: [memory] # loads providers/memory.yaml (see below)
- # hooks / mcp / pi-extensions / runtimes / workflows are accepted here at
- # schema 2 but have no loader in this PR — reserved for later goals.
+ runtimes: [node] # loads runtimes/node.yaml (see managed-runtimes.md)
+ # hooks / mcp / pi-extensions / workflows are accepted here at
+ # schema 2 but have no loader yet — reserved for later goals.
```
#### Provider contributions (`providers/.yaml`)
@@ -433,24 +432,34 @@ Field rules and defaults:
re-validated (realpath-aware) to stay **inside the pack root** — the same containment guard
used for routes/entrypoints. A module that resolves outside the pack root drops the provider.
- **`hooks`** — a subset of the **hook allowlist**: `sessionSetup`, `beforePrompt`,
- `afterTurn`, `beforeCompact`, `sessionShutdown`. An **unknown hook name drops *that*
- provider** (warn) — the rest of the pack still loads. (This is the tolerant
- warn-and-drop contract; only the duplicate-id conflict is hard.)
+ `afterTurn`, `beforeCompact`, `sessionShutdown`, `goalProvisioned`, `goalCompleted`. An
+ **unknown hook name drops *that* provider** (warn) — the rest of the pack still loads.
+ `goalCompleted` is a non-blocking completion hook for outcome/cleanup side effects; it does not
+ inject prompt context. (This is the tolerant warn-and-drop contract; only the duplicate-id
+ conflict is hard.)
- **`budget`** — `{ maxTokens, timeoutMs }`. Defaults `{ maxTokens: 1600, timeoutMs: 1500 }`;
- `maxTokens` is clamped to `[64, 8192]` and `timeoutMs` to `[100, 10000]`. The budget exists
- so the (future) dispatch tier can bound how much a provider may contribute and how long it
- may run.
+ `maxTokens` is clamped to `[64, 8192]` and `timeoutMs` to `[100, 10000]`. The Hub uses it to
+ bound how much a provider may contribute and how long it may run.
- **`runtime?`** / **`config?`** — optional pass-through fields handed to the hook as `ctx.config`.
-**All five hooks are wired (G1.3 + G1.4).** The loader validates providers and the registry
-indexes them, and the `LifecycleHub` runs a provider's `hook` on the worker tier and applies its
-`budget` ([docs/lifecycle-hub.md](lifecycle-hub.md)). A provider that declares `sessionSetup` and
-is installed + active + enabled for the session's scope contributes a **Dynamic Context** prompt
-section; the per-turn `beforePrompt` / `beforeCompact` fire via a generated provider-bridge pi
-extension and `afterTurn` / `sessionShutdown` fire server-side. The first built-in
-production provider is the **[Hindsight memory pack](hindsight-memory.md)** — shipped in the
-built-in band but **dormant until a Hindsight URL is configured**, so a fresh install still
-contributes nothing until you opt in.
+**Provider hooks are wired.** The loader validates providers and the registry indexes them, and
+the `LifecycleHub` runs a provider's `hook` on the worker tier and applies its `budget`
+([docs/lifecycle-hub.md](lifecycle-hub.md)). A provider that declares `sessionSetup` and is
+installed + active + enabled for the session's scope contributes a spawn-time **Dynamic Context**
+system-prompt section. Per-turn `beforePrompt` blocks fire via the generated provider-bridge pi
+extension and are delivered as hidden `bobbit:dynamic-context` custom/user-side messages, not
+appended to `systemPrompt`; `beforeCompact` also fires through the bridge but does not amend
+prompt content. `afterTurn` / `sessionShutdown` fire server-side, and `goalCompleted` fires
+non-blockingly after goal completion for outcome side effects.
+
+The first built-in production provider is the **[Hindsight memory pack](hindsight-memory.md)** —
+shipped with `defaultDisabled: true`, so a fresh unconfigured install contributes nothing until
+setup enables/configures it (or existing persisted Hindsight config preserves activation). Its
+installed row is the **primary setup path**: a Hindsight-specific status strip renders the
+[eight-state badge model, active config, and state-aware actions](hindsight-memory.md#setup-ux--marketplace-front-door-state-model--guided-setup)
+(Configure, Test connection, Open Hindsight UI, Start/Stop runtime, View logs) off a generic seam,
+not a change to every pack's row.
+
#### Why providers are pack-scoped, *not* name-merged
Provider contributions are keyed `(packId, contributionId)` and loaded through the pack-contribution path —
diff --git a/docs/rest-api.md b/docs/rest-api.md
index 508f472b2..55064bedf 100644
--- a/docs/rest-api.md
+++ b/docs/rest-api.md
@@ -70,7 +70,7 @@ These endpoints expose restart support only for gateways launched through `npm r
| `GET` | `/api/sessions/:id/tool-content/:messageIndex/:blockIndex` | Lazy-load full tool input content for a truncated block (see [Large content truncation](#large-content-truncation)) |
| `GET` | `/api/sessions/:id/transcript` | Paginated, regex-filterable transcript reader. Backs the `read_session` tool. Query params: `offset` (negative = from end), `limit` (default 20, clamped 1..200), `pattern`, `case_sensitive`, `context` (±5 max), `verbose`. Same-project authorization via the `x-bobbit-session-id` request header. Errors: `session_not_found` (404), `transcript_unavailable` (404), `invalid_regex` / `invalid_params` (400), `permission_denied` (403). Pure parser lives in `src/server/agent/transcript-reader.ts`. |
| `GET` | `/api/sessions/:id/transcript/before-compaction` | Paginated read of the orphaned pre-compaction entries for a single compaction event. Query params: `compactionId` (required, sidecar entry id), `cursor` (from previous response's `nextCursor`), `limit` (default 50, clamped 1..200). Response envelope `{ total, returned, nextCursor, messages[] }`. Same-project authorization via the `x-bobbit-session-id` header. Errors: `session_not_found` (404), `transcript_unavailable` (404), `compaction_not_found` (404), `invalid_params` (400), `permission_denied` (403). Branch-split via the sidecar's `firstKeptEntryId`; legacy fallback scans the JSONL for an inline `type:"compaction"` marker. Reader: `readOrphanedBeforeCompaction` in `src/server/agent/transcript-reader.ts`. See [docs/compaction-history.md](compaction-history.md). |
-| `POST` | `/api/sessions/:id/provider-hooks/before-prompt` | Per-turn lifecycle dispatch, called only by the generated provider-bridge pi extension. Body `{ prompt?, turn?: { index } }`. Dispatches the `beforePrompt` hook and returns `{ tail, blocks }` — `tail` is the delimited, fenced dynamic-context region appended to the system-prompt tail (or `""`), `blocks` is metadata-only `{ id, providerId, title, tokenEstimate }[]`. `404` for unknown session; `{ tail: "", blocks: [] }` when no Lifecycle Hub is configured. See [docs/lifecycle-hub.md](lifecycle-hub.md#per-turn--lifecycle-wiring-g14). |
+| `POST` | `/api/sessions/:id/provider-hooks/before-prompt` | Per-turn lifecycle dispatch, called only by the generated provider-bridge pi extension. Body `{ prompt?, turn?: { index } }`. Dispatches the `beforePrompt` hook and returns `{ content, blocks }` — `content` is the fenced dynamic-context text delivered by the bridge as a hidden `bobbit:dynamic-context` custom/user-side message (or `""`), `blocks` is metadata-only `{ id, providerId, title, tokenEstimate }[]`. The endpoint also refreshes the prompt inspector's Dynamic Context snapshot best-effort; it does not return or mutate a system-prompt tail. `404` for unknown session; `{ content: "", blocks: [] }` when no Lifecycle Hub is configured. See [docs/lifecycle-hub.md](lifecycle-hub.md#per-turn--lifecycle-wiring-g14). |
| `POST` | `/api/sessions/:id/provider-hooks/before-compact` | Per-turn dispatch from the provider-bridge extension before transcript compaction. Dispatches `beforeCompact` and returns `{}` once provider flushes settle (bounded by per-provider timeouts). `404` for unknown session. |
| `GET` | `/api/sessions/:id/context-trace?limit=N` | Per-turn provider-dispatch trace for diagnostics. Returns `{ entries }` oldest→newest from `ContextTraceStore`; `limit` keeps the most recent N (clamped to 1000). Each entry records the hook, timestamp, and per-provider timing / blocks-kept / omitted / error. See [docs/lifecycle-hub.md](lifecycle-hub.md#the-trace-store). |
| `GET` | `/api/sessions/:id/google-code-assist/token` | Short-lived runtime material for the agent-side Code Assist (`google-gemini-cli`) provider extension: `{ accessToken, projectId }`. Refreshes the stored Google OAuth token per request; **never** returns the OAuth refresh token. `401 { code: "GOOGLE_CODE_ASSIST_REAUTH" }` when no account is signed in or the token can't be refreshed (prompts re-auth, not an API key); `502 { code: "GOOGLE_CODE_ASSIST_PROJECT" }` when the token is valid but project onboarding failed. `projectId` honors `GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_PROJECT_ID` when set. See [Google OAuth & Gemini models](google-oauth-models.md#per-request-token--project-endpoint). |
diff --git a/market-packs/hindsight/entrypoints/hindsight-route.yaml b/market-packs/hindsight/entrypoints/hindsight-route.yaml
new file mode 100644
index 000000000..7c26021bc
--- /dev/null
+++ b/market-packs/hindsight/entrypoints/hindsight-route.yaml
@@ -0,0 +1,10 @@
+id: hindsight.route
+kind: route
+routeId: hindsight
+target:
+ panelId: hindsight.dashboard
+# Deep-link: #/ext/hindsight resolves through the client pack-route registry and
+# reopens the singleton embedded-dashboard panel (the USE surface, NOT config). No
+# params are carried — the panel rehydrates its state entirely from the
+# config/status routes on mount, so paramKeys is empty.
+paramKeys: []
diff --git a/market-packs/hindsight/entrypoints/hindsight-session-menu.yaml b/market-packs/hindsight/entrypoints/hindsight-session-menu.yaml
new file mode 100644
index 000000000..51a3158a3
--- /dev/null
+++ b/market-packs/hindsight/entrypoints/hindsight-session-menu.yaml
@@ -0,0 +1,16 @@
+id: hindsight.session-menu
+kind: session-menu
+label: Hindsight Memory
+# A session-menu LAUNCHER (the sidebar/chat session actions overflow menu),
+# surfaced alongside PR Walkthrough. Replaces the legacy command-palette +
+# git-widget-button entrypoints (those kinds were removed when pack launchers
+# moved to session menus, #829). Unlike pr-walkthrough's spawn launcher this is a
+# BARE PanelTarget (NO action:"spawn"): its selection opens hindsight.dashboard in
+# the ACTIVE (owner) session via openPackPanel — the embedded-dashboard USE
+# surface (NOT the config panel), with no sub-agent to mint. Configuration lives
+# in the Marketplace; this is the in-session use/view/query surface. The dashboard
+# panel renders its own empty state (pointing at the Marketplace) when uiUrl is
+# unset, so the entry stays pack-agnostic (no per-render pack-route calls / no
+# "show only when configured" gate).
+target:
+ panelId: hindsight.dashboard
diff --git a/market-packs/hindsight/lib/HindsightDashboardPanel.js b/market-packs/hindsight/lib/HindsightDashboardPanel.js
new file mode 100644
index 000000000..865a31ede
--- /dev/null
+++ b/market-packs/hindsight/lib/HindsightDashboardPanel.js
@@ -0,0 +1,61 @@
+var f=(a,d="")=>a==null?d:String(a),b=a=>a&&a.message?String(a.message):String(a);var U="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox",g=globalThis.__bobbitHindsightDashboardState||(globalThis.__bobbitHindsightDashboardState=new Map);function k(){return{mountKicked:!1,loadState:"loading",loadError:null,uiUrl:"",externalUrl:"",host:"",frameArmedFor:null,frameLoaded:!1,frameTimedOut:!1,frameTimer:null}}function R(a){let d=f(a,"").trim();if(!d)return"";try{return new URL(d).host||d}catch{return d}}function L(a=""){let d=globalThis.__bobbitHindsightIframeTimeoutMs;if(typeof d=="number"&&Number.isFinite(d)&&d>=0)return d;let l=String(a||""),s=l.match(/[?&](?:amp;)?__bobbit_hindsight_timeout_ms=(\d+)/);if(s)return Number(s[1]);try{let c=Number(new URL(l).searchParams.get("__bobbit_hindsight_timeout_ms"));if(Number.isFinite(c)&&c>=0)return c}catch{}return 7e3}function E(a=""){if(globalThis.__bobbitHindsightIframeForceTimeout===!0)return!0;let d=String(a||"");if(/[?&](?:amp;)?__bobbit_hindsight_force_timeout=1(?:&|$)/.test(d))return!0;try{return new URL(d).searchParams.get("__bobbit_hindsight_force_timeout")==="1"}catch{return!1}}function S({html:a,nothing:d}){let l=e=>{try{e&&e.requestRender&&e.requestRender()}catch{}},s=e=>g.get(e),c=e=>{if(e&&e.frameTimer){try{clearTimeout(e.frameTimer)}catch{}e.frameTimer=null}};async function x(e,o){let r=null,i=null,t=null;try{r=await e.callRoute("config",{method:"GET"})}catch(m){t=b(m)}try{i=await e.callRoute("status",{method:"GET"})}catch(m){t||(t=b(m))}let n=s(o);if(!n)return;let p=r&&r.config||{},u=f(i&&i.uiUrl||p.uiUrl||r&&r.uiUrl,"").trim(),w=f(i&&i.externalUrl||p.externalUrl||r&&r.externalUrl,"").trim();n.uiUrl=u,n.externalUrl=w,n.host=R(u),!u&&t&&!r&&!i?(n.loadState="error",n.loadError=t):(n.loadState="ready",n.loadError=null),l(e)}let v=(e,o,r)=>{let i=s(o);if(!i||i.frameArmedFor===r)return;c(i),i.frameArmedFor=r,i.frameLoaded=!1,i.frameTimedOut=!1;let t=L(r);i.frameTimer=setTimeout(()=>{let n=s(o);n&&(n.frameTimer=null,n.frameLoaded||(n.frameTimedOut=!0,l(e)))},t)},y=(e,o)=>{let r=s(o);r&&(E(r.uiUrl)||(c(r),r.frameLoaded=!0,r.frameTimedOut=!1,l(e)))},h=a``,T=e=>a`
+ ${h}
+
+
+
+
Hindsight dashboard URL is not configured.
+
+ The embedded Hindsight dashboard opens the human UI at your configured
+ dashboard URL. Configure it in the Marketplace to view and query memory
+ without leaving Bobbit.
+
+ ${e.externalUrl?a`
The data-plane API URL (${e.externalUrl}) is configured, but the dashboard UI URL is missing.
`,te=[{preset:"hermes",title:"Hermes-local / embedded",mode:"external",bobbit:"Nothing \u2014 client only",you:"Hermes runs Hindsight for you",note:`Preset: API ${S}, bank hermes. No Docker.`},{preset:"external",title:"Connect existing Hindsight",mode:"external",bobbit:"Nothing \u2014 client only",you:"The whole Hindsight deployment",note:"No Docker \u2014 Bobbit only talks to a URL you provide."},{preset:"managed",title:"Bobbit-managed (recommended)",mode:"managed",bobbit:"Docker: Hindsight API + Postgres",you:"An LLM API key; a data dir",note:"Starts local Docker containers when you press Start."},{preset:"managed-external-postgres",title:"Bobbit-managed + your Postgres",mode:"managed-external-postgres",bobbit:"Docker: Hindsight API",you:"Postgres URL; LLM key",note:"Starts local Docker containers when you press Start."}],ae=()=>i`
+
+ Who manages what
+
+
Bobbit-managed Docker runtime
Bobbit runs the Hindsight API + Postgres in Docker; you supply an LLM key + data dir.
+
Bobbit-managed + external Postgres
Bobbit runs the Hindsight API; you supply a Postgres URL + LLM key.
+
Existing external Hindsight
You run the whole deployment; Bobbit is a client of your API URL.
+
Hermes-local / embedded
Hermes runs Hindsight for you (e.g. ${S}); Bobbit just connects.
+
+
`,se=()=>i`
+
+ Recommended defaults
+
+
Data locality
Local / private — your memory stays on your machine unless you point at a shared deployment.
+
Bank
bobbit (shared, tag-scoped). Use an existing bank like hermes only when connecting to one.
+
Namespace
default unless your Hindsight uses namespaces.
+
Auto-retain
On (async) — memories are saved in the background after each turn; no latency cost.
+
Auto-recall
On — relevant memories are pulled in automatically.
+
Recall scope
project — this project + shared/global memories (all = every project in the shared bank).
+
Timeout
4000 ms — capped below the provider hook budget so slow recall fails open without stalling a turn indefinitely.
+
LLM key (managed)
You supply it — Bobbit forwards it to the local runtime only; never hardcodes a provider secret.
Pressing Start runtime launches local Docker containers — the Hindsight API${f?"":" + a Postgres database"} on loopback ports. The first start may pull an image and take ~1–2 min. Nothing runs until you press Start; Stop keeps your data.
+
+
${l?"\u2713":"\u2022"} Required inputs: LLM API key${f?" + external Postgres URL":""} ${l?"present (saved)":"missing \u2014 set them in Configuration and Save"}
+
${e.configured&&!e.dirty?"\u2713":"\u2022"} Configuration saved ${e.configured?e.dirty?"\u2014 unsaved changes; Save before starting":"":"\u2014 Save first"}
+
+ ${e.dirty?i`
Save your changes before starting — Start uses the saved configuration, not your unsaved edits.
`:d}
+
+
+
+
+
+
+ ${ne(e)}
+ `},de=(e,s,a)=>{let r=e.draft||y(null),t=r.mode,o=p=>b(s,a,"mode",p.currentTarget.value),n=c(r.externalUrl,"").trim(),l=t==="external"&&n?i`${T(n)?"\u2713 Looks like a valid URL":"\u2717 Must be an http(s) URL"}`:d,h=c(r.uiUrl,"").trim(),f=h?i`${T(h)?"\u2713 Looks like a valid URL":"\u2717 Must be an http(s) URL"}`:d;return i`
+
+
+
Configuration
+
+
+
+ ${e.dirty?i`
+ You have unsaved changes. Save persists them; Discard reverts to the stored config.
+
+
`:d}
+
+
+
+ ${t==="external"?x("API / data-plane URL","hindsight-external-url",r.externalUrl,p=>b(s,a,"externalUrl",p.currentTarget.value),{placeholder:S,hint:`API / data-plane URL Bobbit calls to recall & retain (e.g. ${S}). Activates external mode; empty keeps it dormant.`,validity:l}):d}
+
+ ${x("Dashboard UI URL","hindsight-ui-url",r.uiUrl,p=>b(s,a,"uiUrl",p.currentTarget.value),{placeholder:R,hint:`Optional human dashboard opened by "Open Hindsight UI" \u2014 never called by Bobbit (e.g. ${R}).`,validity:f})}
+
+ ${v(t)?x("Managed data dir","hindsight-data-dir",r.dataDir,p=>b(s,a,"dataDir",p.currentTarget.value),{placeholder:"~/.hindsight",hint:t==="managed"?"Host bind-mount path for managed Postgres data.":""}):d}
+
+ ${t==="managed-external-postgres"?P("External Postgres URL","hindsight-external-db-url","externalDatabaseUrl",e,s,a,{hint:"\u2192 runtime HINDSIGHT_API_DATABASE_URL. Required to start."}):d}
+
+ ${v(t)?P("LLM API key","hindsight-llm-api-key","llmApiKey",e,s,a,{hint:"\u2192 runtime HINDSIGHT_API_LLM_API_KEY. Required to start."}):d}
+
+ ${P("API key","hindsight-api-key","apiKey",e,s,a,{hint:"Optional bearer token for the Hindsight API."})}
+
+
`}}}export{he as default};
diff --git a/market-packs/hindsight/lib/hindsight-client.mjs b/market-packs/hindsight/lib/hindsight-client.mjs
index f9688e951..1d917753f 100644
--- a/market-packs/hindsight/lib/hindsight-client.mjs
+++ b/market-packs/hindsight/lib/hindsight-client.mjs
@@ -1,3 +1,3 @@
import { createRequire as __bbCreateRequire } from 'node:module';
const require = __bbCreateRequire(import.meta.url);
-var c=class r extends Error{kind;status;constructor(a,u,g){super(u),this.name="HindsightError",this.kind=a,this.status=g,Object.setPrototypeOf(this,r.prototype)}},w=1500,R="default";function f(r){return r?Object.keys(r).sort().map(a=>`${a}:${r[a]}`):[]}function x(r){let a=r.baseUrl.replace(/\/+$/,""),u=r.namespace&&r.namespace.length>0?r.namespace:R,g=r.timeoutMs??w,k=encodeURIComponent(u);function l(t){return`${a}/v1/${k}/banks/${encodeURIComponent(t)}`}function b(t){let n={};return t&&(n["Content-Type"]="application/json"),r.apiKey&&(n.Authorization=`Bearer ${r.apiKey}`),n}async function h(t,n,e){let s=new AbortController,i=!1,y=setTimeout(()=>{i=!0,s.abort()},g);try{return await fetch(n,{method:t,headers:b(e!==void 0),body:e!==void 0?JSON.stringify(e):void 0,signal:s.signal})}catch(m){if(i)throw new c("timeout",`Hindsight request timed out after ${g}ms`);let o=m instanceof Error?m.message:String(m);throw new c("network",`Hindsight network error: ${o}`)}finally{clearTimeout(y)}}async function d(t,n,e){let s=await h(t,n,e);if(!s.ok)throw new c("http",`Hindsight HTTP ${s.status} for ${t} ${n}`,s.status);return s}async function p(t,n,e){return await(await d(t,n,e)).json()}return{async health(){try{return{ok:(await h("GET",`${a}/health`)).ok}}catch{return{ok:!1}}},async ensureBank(t){await d("PUT",l(t),{})},async recall(t,n,e){let s=f(e?.tags),i={query:n};return e?.maxTokens!==void 0&&(i.max_tokens=e.maxTokens),s.length>0&&(i.tags=s,i.tags_match=e?.tagsMatch??"any"),{memories:((await p("POST",`${l(t)}/memories/recall`,i)).results??[]).map(o=>({text:o.text,id:o.id,score:o.score}))}},async retain(t,n,e){let s=f(e?.tags),i={content:n};s.length>0&&(i.tags=s),await d("POST",`${l(t)}/memories`,{items:[i],async:!e?.sync})},async reflect(t,n){return{text:(await p("POST",`${l(t)}/reflect`,{query:n})).text}},async listBanks(){return{banks:((await p("GET",`${a}/v1/${k}/banks`)).banks??[]).map(n=>n.bank_id)}}}}export{c as HindsightError,x as createClient};
+var u=class r extends Error{kind;status;constructor(s,M,m){super(M),this.name="HindsightError",this.kind=s,this.status=m,Object.setPrototypeOf(this,r.prototype)}},T=4e3,x="default";function y(r){return r?Object.keys(r).sort().map(s=>`${s}:${r[s]}`):[]}function v(r){let s={name:r.name,source_query:r.sourceQuery};return r.id&&(s.id=r.id),r.tags&&r.tags.length>0&&(s.tags=[...r.tags]),r.maxTokens!==void 0&&(s.max_tokens=r.maxTokens),r.trigger!==void 0&&(s.trigger=r.trigger),s}function _(r){let s={...r};return r.sourceQuery!==void 0&&(s.source_query=r.sourceQuery,delete s.sourceQuery),r.maxTokens!==void 0&&(s.max_tokens=r.maxTokens,delete s.maxTokens),s}function h(r){let s={...r};return r.isActive!==void 0&&(s.is_active=r.isActive,delete s.isActive),s}function $(r){let s=r.baseUrl.replace(/\/+$/,""),M=r.namespace&&r.namespace.length>0?r.namespace:x,m=r.timeoutMs??T,k=encodeURIComponent(M);function o(n){return`${s}/v1/${k}/banks/${encodeURIComponent(n)}`}function b(n){let t={};return n&&(t["Content-Type"]="application/json"),r.apiKey&&(t.Authorization=`Bearer ${r.apiKey}`),t}async function f(n,t,e){let a=new AbortController,i=!1,l=setTimeout(()=>{i=!0,a.abort()},m);try{return await fetch(t,{method:n,headers:b(e!==void 0),body:e!==void 0?JSON.stringify(e):void 0,signal:a.signal})}catch(p){if(i)throw new u("timeout",`Hindsight request timed out after ${m}ms`);let g=p instanceof Error?p.message:String(p);throw new u("network",`Hindsight network error: ${g}`)}finally{clearTimeout(l)}}async function R(n){try{let t=await n.text();if(!t)return"";try{let e=JSON.parse(t);return typeof e?.detail=="string"?e.detail:t}catch{return t}}catch{return""}}async function c(n,t,e){let a=await f(n,t,e);if(!a.ok){let i=await R(a),l=i?`: ${i}`:"";throw new u("http",`Hindsight HTTP ${a.status} for ${n} ${t}${l}`,a.status)}return a}async function d(n,t,e){return await(await c(n,t,e)).json()}async function w(n,t,e){let i=await(await c(n,t,e)).text();return i?JSON.parse(i):void 0}async function P(n){let t=await f("GET",n);if(t.status===404)return null;if(!t.ok){let e=await R(t),a=e?`: ${e}`:"";throw new u("http",`Hindsight HTTP ${t.status} for GET ${n}${a}`,t.status)}return await t.json()}return{async health(){try{return{ok:(await f("GET",`${s}/health`)).ok}}catch{return{ok:!1}}},async ensureBank(n){await c("PUT",o(n),{})},async recall(n,t,e){let a=y(e?.tags),i={query:t};return e?.maxTokens!==void 0&&(i.max_tokens=e.maxTokens),e?.budget!==void 0&&(i.budget=e.budget),e?.types&&e.types.length>0&&(i.types=[...e.types]),e?.include!==void 0&&(i.include=e.include),e?.queryTimestamp!==void 0&&(i.query_timestamp=e.queryTimestamp),e?.trace!==void 0&&(i.trace=e.trace),a.length>0&&(i.tags=a,i.tags_match=e?.tagsMatch??"any"),{memories:((await d("POST",`${o(n)}/memories/recall`,i)).results??[]).map(g=>({text:g.text,id:g.id,score:g.score}))}},async retain(n,t,e){let a=y(e?.tags),i={content:t};a.length>0&&(i.tags=a),e?.documentId!==void 0&&(i.document_id=e.documentId),e?.updateMode!==void 0&&(i.update_mode=e.updateMode),e?.entities!==void 0&&(i.entities=e.entities.map(l=>({...l}))),e?.timestamp!==void 0&&(i.timestamp=e.timestamp),e?.observationScopes!==void 0&&(i.observation_scopes=e.observationScopes),e?.metadata!==void 0&&(i.metadata={...e.metadata}),await c("POST",`${o(n)}/memories`,{items:[i],async:!e?.sync})},async reflect(n,t,e){let a=y(e?.tags),i={query:t};e?.budget!==void 0&&(i.budget=e.budget),e?.maxTokens!==void 0&&(i.max_tokens=e.maxTokens),e?.include!==void 0&&(i.include=e.include),e?.responseSchema!==void 0&&(i.response_schema=e.responseSchema),e?.factTypes&&e.factTypes.length>0&&(i.fact_types=[...e.factTypes]),e?.excludeMentalModels!==void 0&&(i.exclude_mental_models=e.excludeMentalModels),e?.excludeMentalModelIds&&e.excludeMentalModelIds.length>0&&(i.exclude_mental_model_ids=[...e.excludeMentalModelIds]),a.length>0&&(i.tags=a,i.tags_match=e?.tagsMatch??"any");let l=await d("POST",`${o(n)}/reflect`,i);return{text:l.text,...l.structured_output!==void 0?{structuredOutput:l.structured_output}:{}}},async listBanks(){return{banks:((await d("GET",`${s}/v1/${k}/banks`)).banks??[]).map(t=>t.bank_id)}},async updateBankConfig(n,t){await c("PATCH",`${o(n)}/config`,{updates:t})},async getMentalModel(n,t){return P(`${o(n)}/mental-models/${encodeURIComponent(t)}`)},async listMentalModels(n){let t=await d("GET",`${o(n)}/mental-models`);return{items:t.items??t.mental_models??[]}},async createMentalModel(n,t){let e=await d("POST",`${o(n)}/mental-models`,v(t));return{mentalModelId:e.mental_model_id,operationId:e.operation_id}},async ensureMentalModel(n,t){if(t.id){let e=await this.getMentalModel(n,t.id);if(e)return{model:e,created:!1}}try{let e=await this.createMentalModel(n,t),a=t.id??e.mentalModelId;return{model:a?await this.getMentalModel(n,a):null,created:!0,operationId:e.operationId}}catch(e){if(t.id&&e instanceof u&&e.kind==="http"&&e.status===409)return{model:await this.getMentalModel(n,t.id),created:!1};throw e}},async updateMentalModel(n,t,e){return d("PATCH",`${o(n)}/mental-models/${encodeURIComponent(t)}`,_(e))},async refreshMentalModel(n,t){return{operationId:(await d("POST",`${o(n)}/mental-models/${encodeURIComponent(t)}/refresh`,{})).operation_id}},async clearMentalModel(n,t){return{operationId:(await d("POST",`${o(n)}/mental-models/${encodeURIComponent(t)}/clear`,{})).operation_id}},async getMentalModelHistory(n,t){return{history:(await d("GET",`${o(n)}/mental-models/${encodeURIComponent(t)}/history`)).history??[]}},async listDirectives(n){let t=await d("GET",`${o(n)}/directives`);return{items:t.items??t.directives??[]}},async createDirective(n,t){return d("POST",`${o(n)}/directives`,h(t))},async updateDirective(n,t,e){return d("PATCH",`${o(n)}/directives/${encodeURIComponent(t)}`,h(e))},async deleteDirective(n,t){await c("DELETE",`${o(n)}/directives/${encodeURIComponent(t)}`)},async llmHealth(n){return d("POST",`${o(n)}/health/llm`,{})},async listOperations(n){let t=await d("GET",`${o(n)}/operations`);return{items:t.items??t.operations??[]}},async retryOperation(n,t){return await w("POST",`${o(n)}/operations/${encodeURIComponent(t)}/retry`,{})??{ok:!0}},async deleteOperation(n,t){await c("DELETE",`${o(n)}/operations/${encodeURIComponent(t)}`)},async invalidateMemory(n,t,e){await c("PATCH",`${o(n)}/memories/${encodeURIComponent(t)}`,{state:"invalidated",reason:e})},async getMemoryHistory(n,t){return{history:(await d("GET",`${o(n)}/memories/${encodeURIComponent(t)}/history`)).history??[]}},async deleteMemoryObservations(n,t){await c("DELETE",`${o(n)}/memories/${encodeURIComponent(t)}/observations`)}}}export{u as HindsightError,$ as createClient};
diff --git a/market-packs/hindsight/lib/provider.mjs b/market-packs/hindsight/lib/provider.mjs
index 6f2904fd5..7a2c20cce 100644
--- a/market-packs/hindsight/lib/provider.mjs
+++ b/market-packs/hindsight/lib/provider.mjs
@@ -1,6 +1,11 @@
import { createRequire as __bbCreateRequire } from 'node:module';
const require = __bbCreateRequire(import.meta.url);
-var N=Object.defineProperty;var D=(e,t,n)=>()=>{if(n)throw n[0];try{return e&&(t=e(e=0)),t}catch(r){throw n=[r],r}};var G=(e,t)=>{for(var n in t)N(e,n,{get:t[n],enumerable:!0})};var j={};G(j,{HindsightError:()=>y,createClient:()=>V});function U(e){return e?Object.keys(e).sort().map(t=>`${t}:${e[t]}`):[]}function V(e){let t=e.baseUrl.replace(/\/+$/,""),n=e.namespace&&e.namespace.length>0?e.namespace:J,r=e.timeoutMs??Y,o=encodeURIComponent(n);function c(i){return`${t}/v1/${o}/banks/${encodeURIComponent(i)}`}function m(i){let s={};return i&&(s["Content-Type"]="application/json"),e.apiKey&&(s.Authorization=`Bearer ${e.apiKey}`),s}async function f(i,s,a){let l=new AbortController,u=!1,B=setTimeout(()=>{u=!0,l.abort()},r);try{return await fetch(s,{method:i,headers:m(a!==void 0),body:a!==void 0?JSON.stringify(a):void 0,signal:l.signal})}catch(v){if(u)throw new y("timeout",`Hindsight request timed out after ${r}ms`);let w=v instanceof Error?v.message:String(v);throw new y("network",`Hindsight network error: ${w}`)}finally{clearTimeout(B)}}async function k(i,s,a){let l=await f(i,s,a);if(!l.ok)throw new y("http",`Hindsight HTTP ${l.status} for ${i} ${s}`,l.status);return l}async function C(i,s,a){return await(await k(i,s,a)).json()}return{async health(){try{return{ok:(await f("GET",`${t}/health`)).ok}}catch{return{ok:!1}}},async ensureBank(i){await k("PUT",c(i),{})},async recall(i,s,a){let l=U(a?.tags),u={query:s};return a?.maxTokens!==void 0&&(u.max_tokens=a.maxTokens),l.length>0&&(u.tags=l,u.tags_match=a?.tagsMatch??"any"),{memories:((await C("POST",`${c(i)}/memories/recall`,u)).results??[]).map(w=>({text:w.text,id:w.id,score:w.score}))}},async retain(i,s,a){let l=U(a?.tags),u={content:s};l.length>0&&(u.tags=l),await k("POST",`${c(i)}/memories`,{items:[u],async:!a?.sync})},async reflect(i,s){return{text:(await C("POST",`${c(i)}/reflect`,{query:s})).text}},async listBanks(){return{banks:((await C("GET",`${t}/v1/${o}/banks`)).banks??[]).map(s=>s.bank_id)}}}}var y,Y,J,O=D(()=>{y=class e extends Error{kind;status;constructor(t,n,r){super(n),this.name="HindsightError",this.kind=t,this.status=r,Object.setPrototypeOf(this,e.prototype)}},Y=1500,J="default"});var $=null;function z(e){$=e}async function P(e){return $?$(e):(await Promise.resolve().then(()=>(O(),j))).createClient(e)}var p={mode:"external",bank:"bobbit",namespace:"default",recallScope:"all",autoRecall:!0,autoRetain:!0,recallBudget:1200,timeoutMs:1500};function A(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function g(e,t){if(!A(e))return;let n=e[t];return A(n)&&"default"in n?n.default:n}function x(e){return typeof e=="string"&&e.length>0?e:void 0}function L(e,t){return typeof e=="boolean"?e:t}function _(e,t){return typeof e=="number"&&Number.isFinite(e)?e:t}function b(e){let t=x(g(e,"externalUrl")),n=x(g(e,"apiKey")),r=g(e,"recallScope")==="project"?"project":"all";return{mode:x(g(e,"mode"))??p.mode,...t?{externalUrl:t}:{},...n?{apiKey:n}:{},bank:x(g(e,"bank"))??p.bank,namespace:x(g(e,"namespace"))??p.namespace,recallScope:r,autoRecall:L(g(e,"autoRecall"),p.autoRecall),autoRetain:L(g(e,"autoRetain"),p.autoRetain),recallBudget:_(g(e,"recallBudget"),p.recallBudget),timeoutMs:_(g(e,"timeoutMs"),p.timeoutMs)}}function h(e){return e.mode==="external"&&typeof e.externalUrl=="string"&&e.externalUrl.trim().length>0}function R(e){return{baseUrl:(e.externalUrl??"").replace(/\/+$/,""),...e.apiKey?{apiKey:e.apiKey}:{},namespace:e.namespace,timeoutMs:e.timeoutMs}}var K="retain-queue",W="last-error";var X=100;async function T(e){try{let t=await e.get(K);return Array.isArray(t)?t:[]}catch{return[]}}async function S(e,t){try{await e.put(K,t)}catch{}}async function H(e,t){let n=await T(e);for(n.push(t);n.length>X;)n.shift();await S(e,n)}async function E(e,t){try{await e.put(W,{message:Z(t),ts:Date.now()})}catch{}}function Z(e){return e&&typeof e=="object"&&"message"in e?String(e.message):String(e)}function I(e,t){return e.length<=t?e:`${e.slice(0,t-1)}\u2026`}var ee="Relevant memory",q=2e3;function M(e){return e?.host?.store??null}function d(e){return typeof e=="string"&&e.trim().length>0?e.trim():void 0}function te(e,t){let n={kind:t};return e.projectId&&(n.project=String(e.projectId)),e.goalId&&(n.goal=String(e.goalId)),e.roleName&&(n.agent=String(e.roleName)),e.sessionId&&(n.session=String(e.sessionId)),n}function ne(e){let t=[],n=d(e.prompt)??d(e.userText),r=d(e.response)??d(e.assistantText);n&&t.push(`User: ${n}`),r&&t.push(`Assistant: ${r}`);let o=t.join(`
+var je=Object.defineProperty;var Ue=(e,t,n)=>()=>{if(n)throw n[0];try{return e&&(t=e(e=0)),t}catch(i){throw n=[i],i}};var Be=(e,t)=>{for(var n in t)je(e,n,{get:t[n],enumerable:!0})};var ae={};Be(ae,{HindsightError:()=>C,createClient:()=>qe});function K(e){return e?Object.keys(e).sort().map(t=>`${t}:${e[t]}`):[]}function Ne(e){let t={name:e.name,source_query:e.sourceQuery};return e.id&&(t.id=e.id),e.tags&&e.tags.length>0&&(t.tags=[...e.tags]),e.maxTokens!==void 0&&(t.max_tokens=e.maxTokens),e.trigger!==void 0&&(t.trigger=e.trigger),t}function Fe(e){let t={...e};return e.sourceQuery!==void 0&&(t.source_query=e.sourceQuery,delete t.sourceQuery),e.maxTokens!==void 0&&(t.max_tokens=e.maxTokens,delete t.maxTokens),t}function oe(e){let t={...e};return e.isActive!==void 0&&(t.is_active=e.isActive,delete t.isActive),t}function qe(e){let t=e.baseUrl.replace(/\/+$/,""),n=e.namespace&&e.namespace.length>0?e.namespace:He,i=e.timeoutMs??Qe,r=encodeURIComponent(n);function s(a){return`${t}/v1/${r}/banks/${encodeURIComponent(a)}`}function l(a){let u={};return a&&(u["Content-Type"]="application/json"),e.apiKey&&(u.Authorization=`Bearer ${e.apiKey}`),u}async function d(a,u,o){let M=new AbortController,f=!1,R=setTimeout(()=>{f=!0,M.abort()},i);try{return await fetch(u,{method:a,headers:l(o!==void 0),body:o!==void 0?JSON.stringify(o):void 0,signal:M.signal})}catch(B){if(f)throw new C("timeout",`Hindsight request timed out after ${i}ms`);let j=B instanceof Error?B.message:String(B);throw new C("network",`Hindsight network error: ${j}`)}finally{clearTimeout(R)}}async function m(a){try{let u=await a.text();if(!u)return"";try{let o=JSON.parse(u);return typeof o?.detail=="string"?o.detail:u}catch{return u}}catch{return""}}async function c(a,u,o){let M=await d(a,u,o);if(!M.ok){let f=await m(M),R=f?`: ${f}`:"";throw new C("http",`Hindsight HTTP ${M.status} for ${a} ${u}${R}`,M.status)}return M}async function g(a,u,o){return await(await c(a,u,o)).json()}async function h(a,u,o){let f=await(await c(a,u,o)).text();return f?JSON.parse(f):void 0}async function v(a){let u=await d("GET",a);if(u.status===404)return null;if(!u.ok){let o=await m(u),M=o?`: ${o}`:"";throw new C("http",`Hindsight HTTP ${u.status} for GET ${a}${M}`,u.status)}return await u.json()}return{async health(){try{return{ok:(await d("GET",`${t}/health`)).ok}}catch{return{ok:!1}}},async ensureBank(a){await c("PUT",s(a),{})},async recall(a,u,o){let M=K(o?.tags),f={query:u};return o?.maxTokens!==void 0&&(f.max_tokens=o.maxTokens),o?.budget!==void 0&&(f.budget=o.budget),o?.types&&o.types.length>0&&(f.types=[...o.types]),o?.include!==void 0&&(f.include=o.include),o?.queryTimestamp!==void 0&&(f.query_timestamp=o.queryTimestamp),o?.trace!==void 0&&(f.trace=o.trace),M.length>0&&(f.tags=M,f.tags_match=o?.tagsMatch??"any"),{memories:((await g("POST",`${s(a)}/memories/recall`,f)).results??[]).map(j=>({text:j.text,id:j.id,score:j.score}))}},async retain(a,u,o){let M=K(o?.tags),f={content:u};M.length>0&&(f.tags=M),o?.documentId!==void 0&&(f.document_id=o.documentId),o?.updateMode!==void 0&&(f.update_mode=o.updateMode),o?.entities!==void 0&&(f.entities=o.entities.map(R=>({...R}))),o?.timestamp!==void 0&&(f.timestamp=o.timestamp),o?.observationScopes!==void 0&&(f.observation_scopes=o.observationScopes),o?.metadata!==void 0&&(f.metadata={...o.metadata}),await c("POST",`${s(a)}/memories`,{items:[f],async:!o?.sync})},async reflect(a,u,o){let M=K(o?.tags),f={query:u};o?.budget!==void 0&&(f.budget=o.budget),o?.maxTokens!==void 0&&(f.max_tokens=o.maxTokens),o?.include!==void 0&&(f.include=o.include),o?.responseSchema!==void 0&&(f.response_schema=o.responseSchema),o?.factTypes&&o.factTypes.length>0&&(f.fact_types=[...o.factTypes]),o?.excludeMentalModels!==void 0&&(f.exclude_mental_models=o.excludeMentalModels),o?.excludeMentalModelIds&&o.excludeMentalModelIds.length>0&&(f.exclude_mental_model_ids=[...o.excludeMentalModelIds]),M.length>0&&(f.tags=M,f.tags_match=o?.tagsMatch??"any");let R=await g("POST",`${s(a)}/reflect`,f);return{text:R.text,...R.structured_output!==void 0?{structuredOutput:R.structured_output}:{}}},async listBanks(){return{banks:((await g("GET",`${t}/v1/${r}/banks`)).banks??[]).map(u=>u.bank_id)}},async updateBankConfig(a,u){await c("PATCH",`${s(a)}/config`,{updates:u})},async getMentalModel(a,u){return v(`${s(a)}/mental-models/${encodeURIComponent(u)}`)},async listMentalModels(a){let u=await g("GET",`${s(a)}/mental-models`);return{items:u.items??u.mental_models??[]}},async createMentalModel(a,u){let o=await g("POST",`${s(a)}/mental-models`,Ne(u));return{mentalModelId:o.mental_model_id,operationId:o.operation_id}},async ensureMentalModel(a,u){if(u.id){let o=await this.getMentalModel(a,u.id);if(o)return{model:o,created:!1}}try{let o=await this.createMentalModel(a,u),M=u.id??o.mentalModelId;return{model:M?await this.getMentalModel(a,M):null,created:!0,operationId:o.operationId}}catch(o){if(u.id&&o instanceof C&&o.kind==="http"&&o.status===409)return{model:await this.getMentalModel(a,u.id),created:!1};throw o}},async updateMentalModel(a,u,o){return g("PATCH",`${s(a)}/mental-models/${encodeURIComponent(u)}`,Fe(o))},async refreshMentalModel(a,u){return{operationId:(await g("POST",`${s(a)}/mental-models/${encodeURIComponent(u)}/refresh`,{})).operation_id}},async clearMentalModel(a,u){return{operationId:(await g("POST",`${s(a)}/mental-models/${encodeURIComponent(u)}/clear`,{})).operation_id}},async getMentalModelHistory(a,u){return{history:(await g("GET",`${s(a)}/mental-models/${encodeURIComponent(u)}/history`)).history??[]}},async listDirectives(a){let u=await g("GET",`${s(a)}/directives`);return{items:u.items??u.directives??[]}},async createDirective(a,u){return g("POST",`${s(a)}/directives`,oe(u))},async updateDirective(a,u,o){return g("PATCH",`${s(a)}/directives/${encodeURIComponent(u)}`,oe(o))},async deleteDirective(a,u){await c("DELETE",`${s(a)}/directives/${encodeURIComponent(u)}`)},async llmHealth(a){return g("POST",`${s(a)}/health/llm`,{})},async listOperations(a){let u=await g("GET",`${s(a)}/operations`);return{items:u.items??u.operations??[]}},async retryOperation(a,u){return await h("POST",`${s(a)}/operations/${encodeURIComponent(u)}/retry`,{})??{ok:!0}},async deleteOperation(a,u){await c("DELETE",`${s(a)}/operations/${encodeURIComponent(u)}`)},async invalidateMemory(a,u,o){await c("PATCH",`${s(a)}/memories/${encodeURIComponent(u)}`,{state:"invalidated",reason:o})},async getMemoryHistory(a,u){return{history:(await g("GET",`${s(a)}/memories/${encodeURIComponent(u)}/history`)).history??[]}},async deleteMemoryObservations(a,u){await c("DELETE",`${s(a)}/memories/${encodeURIComponent(u)}/observations`)}}}var C,Qe,He,le=Ue(()=>{C=class e extends Error{kind;status;constructor(t,n,i){super(n),this.name="HindsightError",this.kind=t,this.status=i,Object.setPrototypeOf(this,e.prototype)}},Qe=4e3,He="default"});var Ge=["observation","world","experience"];function J(e,t,n="any",i){let r=typeof t=="string"&&t.trim().length>0?t.trim():void 0,s=i&&Object.keys(i).length>0?{...i}:void 0;if(e==="project"&&r){let l={...s??{}};return delete l.project,Object.keys(l).length>0?{tags:{project:r,...l},tagsMatch:"all_strict"}:{tags:{project:r},tagsMatch:n}}if(s)return{tags:s,tagsMatch:"any"}}function ue(e){return e==="managed"||e==="managed-external-postgres"}var Y=null;function Ke(e){Y=e}async function x(e){return Y?Y(e):(await Promise.resolve().then(()=>(le(),ae))).createClient(e)}var Ve="Capture durable, reusable knowledge: user and team preferences, decisions and their rationale, conventions, standards, architecture, and lasting project state. Ignore transient noise \u2014 stack traces, PIDs, timestamps, one-off command output, failed attempts, greetings, and routine per-turn chatter \u2014 unless it records a decision or changes project state.",Ye="Observations are stable, durable facts about the people and projects in this bank: preferences, conventions, recurring decisions, architecture, and project state. Consolidate repeated facts into general, reusable statements. Ignore one-off events, ephemeral state, and transient per-turn details.",Je="You are a long-term engineering memory for this team. Ground answers in documented decisions, preferences, conventions, and project state, drawing on consolidated observations rather than raw per-turn chatter. Be direct and precise; do not speculate or resurface transient noise.",Xe=[{name:"bobbit-coding-agent-recall",content:"Answer for a coding agent. Prefer recent, durable project facts and decisions; cite source facts when present; ignore transient turn noise unless it records a lasting decision or project-state change.",priority:50,tags:["bobbit"]}],y={mode:"external",dataDir:"~/.hindsight",bank:"bobbit",namespace:"default",recallScope:"project",tagsMatch:"any",autoRecall:!0,autoRetain:!0,retainEveryNTurns:5,retainMaxDelayMs:18e5,retainOverlapTurns:2,recallBudget:1200,recallTypes:[...Ge],recallQueryTimestampEnabled:!0,mentalModelEnabled:!0,mentalModelMaxTokens:1e3,mentalModelRefreshEveryMs:864e5,mentalModelRecallMaxTokens:1200,directivesEnabled:!1,directiveApplyMode:"disabled",directiveSetVersion:"bobbit-v1",directives:Xe.map(e=>({...e,tags:e.tags?[...e.tags]:void 0})),retainQueueDrainMaxPerHook:1,retainQueueShutdownMax:10,retainQueueHealthGate:!0,retainQueueLlmHealthGate:!1,retainQueueBatchPauseMs:0,retainMission:Ve,observationsMission:Ye,reflectMission:Je,recallMaxInputChars:3e3,timeoutMs:4e3};function $(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function p(e,t){if(!$(e))return;let n=e[t];return $(n)&&"default"in n?n.default:n}function b(e){return typeof e=="string"&&e.length>0?e:void 0}function _(e,t){return typeof e=="boolean"?e:t}function k(e,t){return typeof e=="number"&&Number.isFinite(e)?e:t}function ce(e){return e==="observation"||e==="world"||e==="experience"}function ze(e,t){if(!Array.isArray(e))return[...t];let n=e.filter(ce);return n.length>0?[...new Set(n)]:[...t]}function We(e){return e==="disabled"||e==="bank-wide-explicit-opt-in"||e==="scoped-if-supported"}function Ze(e,t){if(!Array.isArray(e))return t.map(i=>({...i,tags:i.tags?[...i.tags]:void 0}));let n=[];for(let i of e)!$(i)||typeof i.name!="string"||i.name.trim().length===0||typeof i.content!="string"||n.push({name:i.name.trim(),content:i.content,...typeof i.priority=="number"&&Number.isFinite(i.priority)?{priority:i.priority}:{},...Array.isArray(i.tags)?{tags:i.tags.filter(r=>typeof r=="string")}:{}});return n.length>0?n:t.map(i=>({...i,tags:i.tags?[...i.tags]:void 0}))}function S(e){let t=b(p(e,"externalUrl")),n=b(p(e,"uiUrl")),i=b(p(e,"apiKey")),r=b(p(e,"externalDatabaseUrl")),s=b(p(e,"llmApiKey")),l=p(e,"recallScope")==="all"?"all":"project",d=p(e,"tagsMatch")==="any_strict"?"any_strict":"any",m=Math.max(1,Math.floor(k(p(e,"retainEveryNTurns"),y.retainEveryNTurns))),c=Math.max(0,Math.floor(k(p(e,"retainMaxDelayMs"),y.retainMaxDelayMs))),g=Math.max(0,Math.floor(k(p(e,"retainOverlapTurns"),y.retainOverlapTurns))),h=p(e,"directiveApplyMode"),v=Math.max(0,Math.floor(k(p(e,"retainQueueDrainMaxPerHook"),y.retainQueueDrainMaxPerHook))),a=Math.max(0,Math.floor(k(p(e,"retainQueueShutdownMax"),y.retainQueueShutdownMax))),u=Math.max(0,Math.floor(k(p(e,"retainQueueBatchPauseMs"),y.retainQueueBatchPauseMs)));return{mode:b(p(e,"mode"))??y.mode,...t?{externalUrl:t}:{},...n?{uiUrl:n}:{},...i?{apiKey:i}:{},...r?{externalDatabaseUrl:r}:{},...s?{llmApiKey:s}:{},dataDir:b(p(e,"dataDir"))??y.dataDir,bank:b(p(e,"bank"))??y.bank,namespace:b(p(e,"namespace"))??y.namespace,recallScope:l,tagsMatch:d,autoRecall:_(p(e,"autoRecall"),y.autoRecall),autoRetain:_(p(e,"autoRetain"),y.autoRetain),retainEveryNTurns:m,retainMaxDelayMs:c,retainOverlapTurns:g,recallBudget:k(p(e,"recallBudget"),y.recallBudget),recallTypes:ze(p(e,"recallTypes"),y.recallTypes),recallQueryTimestampEnabled:_(p(e,"recallQueryTimestampEnabled"),y.recallQueryTimestampEnabled),mentalModelEnabled:_(p(e,"mentalModelEnabled"),y.mentalModelEnabled),mentalModelMaxTokens:Math.max(1,Math.floor(k(p(e,"mentalModelMaxTokens"),y.mentalModelMaxTokens))),mentalModelRefreshEveryMs:Math.max(0,Math.floor(k(p(e,"mentalModelRefreshEveryMs"),y.mentalModelRefreshEveryMs))),mentalModelRecallMaxTokens:Math.max(1,Math.floor(k(p(e,"mentalModelRecallMaxTokens"),y.mentalModelRecallMaxTokens))),directivesEnabled:_(p(e,"directivesEnabled"),y.directivesEnabled),directiveApplyMode:We(h)?h:y.directiveApplyMode,directiveSetVersion:b(p(e,"directiveSetVersion"))??y.directiveSetVersion,directives:Ze(p(e,"directives"),y.directives),retainQueueDrainMaxPerHook:v,retainQueueShutdownMax:a,retainQueueHealthGate:_(p(e,"retainQueueHealthGate"),y.retainQueueHealthGate),retainQueueLlmHealthGate:_(p(e,"retainQueueLlmHealthGate"),y.retainQueueLlmHealthGate),retainQueueBatchPauseMs:u,retainMission:b(p(e,"retainMission"))??y.retainMission,observationsMission:b(p(e,"observationsMission"))??y.observationsMission,reflectMission:b(p(e,"reflectMission"))??y.reflectMission,recallMaxInputChars:k(p(e,"recallMaxInputChars"),y.recallMaxInputChars),timeoutMs:k(p(e,"timeoutMs"),y.timeoutMs)}}var et=1600;function de(e,t){let n=(e??"").trim(),i=Number.isFinite(t)&&t>0?t:Number.POSITIVE_INFINITY,r=Math.min(i,et);return n.length<=r?n:n.slice(0,r)}function me(e){if(!e||typeof e!="object")return!1;let t=e;if(t.kind!=="http"||t.status!==400)return!1;let n=typeof t.message=="string"?t.message.toLowerCase():"";return/\bquery\b/.test(n)?/\btoo\s+(?:long|large|many\s+tokens?)\b/.test(n)?!0:/\b(?:exceeds?|exceeded)\b.*\b(?:max(?:imum)?|limit|tokens?)\b/.test(n)||/\b(?:max(?:imum)?|limit|tokens?)\b.*\b(?:exceeds?|exceeded)\b/.test(n):!1}function A(e,t){return e.mode==="external"?typeof e.externalUrl=="string"&&e.externalUrl.trim().length>0:ue(e.mode)?!t||typeof t.baseUrl!="string"||t.baseUrl.trim().length===0?!1:t.status===void 0||t.status==="running":!1}function ge(e,t){return{baseUrl:(ue(e.mode)?t?.baseUrl??"":e.externalUrl??"").replace(/\/+$/,""),...e.apiKey?{apiKey:e.apiKey}:{},namespace:e.namespace,timeoutMs:e.timeoutMs}}var fe="retain-queue",pe="last-error";var tt="provider-config:memory:project:",nt="bank-config-applied:",it="retain-pending:";var rt="directives-applied:",st=100;function ot(e){return`${tt}${e}`}async function Q(e){try{let t=await e.get(fe);return Array.isArray(t)?t:[]}catch{return[]}}async function H(e,t){try{await e.put(fe,t)}catch{}}async function N(e,t){let n=await Q(e);for(n.push(t);n.length>st;)n.shift();await H(e,n)}async function E(e,t){try{await e.put(pe,{message:at(t),ts:Date.now()})}catch{}}async function O(e){try{await e.put(pe,null)}catch{}}function at(e){return e&&typeof e=="object"&&"message"in e?String(e.message):String(e)}function X(e,t){return e.length<=t?e:`${e.slice(0,t-1)}\u2026`}function ye(e,t=new Date){return e?t.toISOString():void 0}function lt(e){return JSON.stringify({ns:e.namespace,bank:e.bank,mode:e.directiveApplyMode,version:e.directiveSetVersion,directives:e.directives})}async function Me(e,t,n){if(!n.directivesEnabled||n.directiveApplyMode==="disabled"||typeof t.listDirectives!="function"||typeof t.createDirective!="function")return;let i=`${rt}${n.namespace}:${n.bank}`,r=lt(n);if(e)try{if(await e.get(i)===r)return}catch{}try{let s=await t.listDirectives(n.bank);for(let l of n.directives){if(!l.name.startsWith("bobbit-"))continue;let d=s.items.find(c=>c.name===l.name),m={...l,isActive:!0,tags:[...new Set([...l.tags??[],"bobbit"])]};d?.id&&typeof t.updateDirective=="function"?await t.updateDirective(n.bank,d.id,m):await t.createDirective(n.bank,m)}e&&await e.put(i,r)}catch(s){e&&await E(e,s)}}function ut(e){if(!$(e))return{ok:!1,errors:["projectOverride must be an object"]};let t=[],n={},i=r=>r===null||r==="";if("recallScope"in e&&!i(e.recallScope)&&(e.recallScope==="project"||e.recallScope==="all"?n.recallScope=e.recallScope:t.push("recallScope must be 'project' or 'all'")),"bank"in e&&!i(e.bank)&&(typeof e.bank=="string"&&e.bank.trim().length>0?n.bank=e.bank.trim():t.push("bank must be a non-empty string")),"tagsMatch"in e&&!i(e.tagsMatch)&&(e.tagsMatch==="any"||e.tagsMatch==="any_strict"?n.tagsMatch=e.tagsMatch:t.push("tagsMatch must be 'any' or 'any_strict'")),"recallBudget"in e&&!i(e.recallBudget)){let r=e.recallBudget;typeof r=="number"&&Number.isFinite(r)&&r>0?n.recallBudget=r:t.push("recallBudget must be a positive number")}if("recallTypes"in e&&e.recallTypes!==null){let r=e.recallTypes;Array.isArray(r)&&r.length>0&&r.every(ce)?n.recallTypes=[...new Set(r)]:t.push("recallTypes must be a non-empty array of 'observation'|'world'|'experience'")}return t.length>0?{ok:!1,errors:t}:{ok:!0,value:n}}async function ct(e,t){try{let n=await e.get(t);return $(n)?n:void 0}catch{return}}var dt=e=>typeof e=="string"&&e.trim().length>0?e.trim():void 0;async function mt(e,t){let n=dt(t);if(!n)return;let i=await ct(e,ot(n));if(!i)return;let r=ut(i);return r.ok&&r.value&&Object.keys(r.value).length>0?r.value:void 0}async function he(e,t,n){if(!t)return e;let i=await mt(t,n);return i?S({...e,...i}):e}function be(e){let t={};return e.retainMission&&e.retainMission.trim().length>0&&(t.retain_mission=e.retainMission),e.observationsMission&&e.observationsMission.trim().length>0&&(t.observations_mission=e.observationsMission),e.reflectMission&&e.reflectMission.trim().length>0&&(t.reflect_mission=e.reflectMission),t}function gt(e){return JSON.stringify({ns:e.namespace,bank:e.bank,...be(e)})}async function U(e,t,n){let i=be(n);if(Object.keys(i).length===0||typeof t.updateBankConfig!="function")return;let r=`${nt}${n.namespace}:${n.bank}`,s=gt(n);if(e)try{if(await e.get(r)===s)return}catch{}try{if(await t.updateBankConfig(n.bank,i),e)try{await e.put(r,s)}catch{}}catch(l){e&&await E(e,l)}}var V=`
-`).trim();return o?o.slice(0,q):""}function re(e){let t=d(e.summary)??d(e.span)??d(e.prompt);return t?t.slice(0,q):""}async function F(e,t,n){if(!t.autoRecall)return[];let r=(n??"").trim();if(!r)return[];let o=t.recallScope==="project"&&e.projectId?{project:String(e.projectId)}:void 0,c=M(e);try{let k=(await(await P(R(t))).recall(t.bank,r,{maxTokens:t.recallBudget,...o?{tags:o,tagsMatch:"any"}:{}}))?.memories??[];return k.length===0?[]:[{id:"memory:0",title:ee,authority:"memory",priority:50,reason:`Recall for: ${I(r,80)}`,content:k.map(C=>`- ${C.text}`).join(`
-`)}]}catch(m){return c&&await E(c,m),[]}}async function ie(e,t){let n=await T(e);if(n.length===0)return;let r=n[0];try{let o=await P(R(t));await o.ensureBank(t.bank),await o.retain(t.bank,r.content,{tags:r.tags,sync:!1}),n.shift(),await S(e,n)}catch(o){await E(e,o)}}async function oe(e,t){let n=await T(e);if(n.length===0)return;let r;try{r=await P(R(t))}catch{return}let o=[];for(let c of n)try{await r.ensureBank(t.bank),await r.retain(t.bank,c.content,{tags:c.tags,sync:!1})}catch{o.push(c)}await S(e,o)}async function Q(e,t,n,r,o){let c=M(e),m=te(e,r);try{let f=await P(R(t));await f.ensureBank(t.bank),await f.retain(t.bank,n,{tags:m,sync:o})}catch(f){c&&(await H(c,{content:n,tags:m,ts:Date.now()}),await E(c,f))}}var se={async sessionSetup(e){let t=b(e.config);return h(t)?{blocks:await F(e,t,e.prompt)}:{blocks:[]}},async beforePrompt(e){let t=b(e.config);return h(t)?{blocks:await F(e,t,e.prompt)}:{blocks:[]}},async afterTurn(e){let t=b(e.config);if(!h(t)||!t.autoRetain)return{blocks:[]};let n=M(e);n&&await ie(n,t);let r=ne(e);return r&&await Q(e,t,r,"turn",!1),{blocks:[]}},async beforeCompact(e){let t=b(e.config);if(!h(t)||!t.autoRetain)return{blocks:[]};let n=re(e);return n&&await Q(e,t,n,"compaction",!0),{blocks:[]}},async sessionShutdown(e){let t=b(e.config);if(!h(t))return{blocks:[]};let n=M(e);return n&&await oe(n,t),{blocks:[]}}},ue=se;export{z as __setClientFactory,ue as default};
+---
+
+`;function ke(e){return`${it}${e}`}function ft(e){return Array.isArray(e)?e.filter(t=>$(t)&&typeof t.summary=="string").map(t=>({summary:String(t.summary),ts:typeof t.ts=="number"&&Number.isFinite(t.ts)?t.ts:0})):[]}async function z(e,t){try{let n=await e.get(ke(t));if($(n))return{turns:ft(n.turns),overlap:Array.isArray(n.overlap)?n.overlap.filter(i=>typeof i=="string"):[]}}catch{}return{turns:[],overlap:[]}}async function W(e,t,n){try{await e.put(ke(t),n)}catch{}}function ve(e,t,n,i){if(e.turns.length===0)return!1;let r=Number.isFinite(t)&&t>=1?Math.floor(t):1;if(e.turns.length>=r)return!0;if(Number.isFinite(n)&&n>0){let s=e.turns[0]?.ts??i;if(i-s>=n)return!0}return!1}function we(e){let t=[];e.overlap.length>0&&t.push(`Earlier context (overlap):${V}${e.overlap.join(V)}`);for(let n of e.turns)t.push(n.summary);return t.join(V)}function Re(e,t){let n=Number.isFinite(t)&&t>=1?Math.floor(t):0;return n<=0?[]:e.slice(-n).map(i=>i.summary)}var pt="Relevant memory",yt="Project memory model",Se=2e3,Mt=360*60*1e3,ht=1e3,bt=10,Ee=4e3,kt="mental-model-refresh:",vt="goal-completed:",Z=new Set;function D(e,t){let n=ge(e,t),i=typeof n.timeoutMs=="number"&&Number.isFinite(n.timeoutMs)&&n.timeoutMs>0?n.timeoutMs:Ee;return{...n,timeoutMs:Math.min(i,Ee)}}function w(e){return e?.host?.store??null}function F(e){return e.projectId!==void 0?String(e.projectId):void 0}function ee(e){let t=e.sessionId!==void 0?String(e.sessionId).trim():"";return t.length>0?t:void 0}async function L(e,t){let n=await he(t,w(e),F(e)),i=Ie(e),r={};for(let s of["mentalModelEnabled","mentalModelRefreshEveryMs","mentalModelMaxTokens","recallQueryTimestampEnabled","directivesEnabled","directiveApplyMode","directiveSetVersion","directives","retainQueueHealthGate","retainQueueLlmHealthGate","retainQueueDrainMaxPerHook","retainQueueShutdownMax"])s in i&&(r[s]=i[s]);return{...n,...r}}function P(e){return typeof e=="string"&&e.trim().length>0?e.trim():void 0}function ne(e,t){let n={kind:t};return e.projectId&&(n.project=String(e.projectId)),e.goalId&&(n.goal=String(e.goalId)),e.roleName&&(n.agent=String(e.roleName)),e.sessionId&&(n.session=String(e.sessionId)),n}function Ie(e){return e.config&&typeof e.config=="object"&&!Array.isArray(e.config)?e.config:{}}function _e(e,t,n){return t[n]??Ie(e)[n]}function $e(e,t,n,i){let r=_e(e,t,n);return typeof r=="boolean"?r:i}function Ae(e,t,n,i){let r=_e(e,t,n);return typeof r=="number"&&Number.isFinite(r)?r:i}function I(e){if(Array.isArray(e))return e.flatMap(t=>I(t));if(typeof e=="string"&&e.trim())return[e.trim()];if(e&&typeof e=="object"){let t=[];for(let n of Object.values(e))t.push(...I(n));return t}return[]}function Te(e){return[...new Set(e.map(t=>t.trim()).filter(Boolean))]}function wt(e){let t=new Set,n=[];for(let i of e){let r=typeof i.text=="string"?i.text.trim():"";if(!r)continue;let s=typeof i.type=="string"&&i.type.trim().length>0?i.type.trim():void 0,l=`${s??""}\0${r}`;t.has(l)||(t.add(l),n.push(s?{text:r,type:s}:{text:r}))}return n}function Rt(e){return Object.entries(e??{}).map(([t,n])=>`${t}:${n}`)}function ie(e){let t=F(e)?.trim();return t?[[`project:${t}`]]:void 0}function re(e){let t=wt([...Te([...I(e.files),...I(e.touchedFiles),...I(e.changedFiles)]).map(n=>({text:n,type:"file"})),...Te(I(e.components)).map(n=>({text:n,type:"component"}))]);return t.length>0?t:void 0}function q(e,t,n,i={}){return{tags:t,sync:n,...i.documentId?{documentId:i.documentId}:{},...i.updateMode?{updateMode:i.updateMode}:{},...i.entities&&i.entities.length>0?{entities:i.entities}:{},...i.observationScopes&&i.observationScopes.length>0?{observationScopes:i.observationScopes}:{},...i.timestamp?{timestamp:i.timestamp}:{},...i.metadata?{metadata:i.metadata}:{}}}function se(e,t,n,i,r={}){return{content:n,tags:i,ts:Date.now(),bank:t.bank,namespace:t.namespace,...r.documentId?{documentId:r.documentId}:{},...r.updateMode?{updateMode:r.updateMode}:{},...r.entities&&r.entities.length>0?{entities:r.entities}:{},...r.observationScopes&&r.observationScopes.length>0?{observationScopes:r.observationScopes}:{},...r.timestamp?{timestamp:r.timestamp}:{},...r.metadata?{metadata:r.metadata}:{}}}function Et(e){let t=[],n=P(e.prompt)??P(e.userText),i=P(e.response)??P(e.assistantText);n&&t.push(`User: ${n}`),i&&t.push(`Assistant: ${i}`);let r=t.join(`
+
+`).trim();return r?r.slice(0,Se):""}function Tt(e){let t=P(e.summary)??P(e.span)??P(e.prompt);return t?t.slice(0,Se):""}function Pe(e){return Array.isArray(e)?e.filter(t=>!!t&&typeof t=="object"&&!Array.isArray(t)):[]}function T(e,t){let n=e[t];return typeof n=="string"&&n.trim()?n.trim():void 0}function Oe(e){let t=e.prNumber!==void 0?String(e.prNumber).trim():"";return t||(e.pullRequest?.number!==void 0?String(e.pullRequest.number).trim():"")||void 0}function Pt(e,t,n,i){let r=Oe(e),s=P(e.title)??P(e.pullRequest?.title),l=[`Goal completed: ${t}`];if(s&&l.push(`Title: ${s}`),e.branch&&l.push(`Branch: ${e.branch}`),e.mergeTarget&&l.push(`Merge target: ${e.mergeTarget}`),n!=="unknown"&&l.push(`Head SHA: ${n}`),e.completedAt&&l.push(`Completed at: ${e.completedAt}`),e.pullRequest||r){let c=[r?`#${r}`:void 0,e.pullRequest?.state,e.pullRequest?.url].filter(Boolean);l.push(`Pull request: ${c.join(" ")||"known"}`)}for(let c of I(e.achievements))l.push(`Achievement: ${c}`);for(let c of I(e.decisions))l.push(`Decision: ${c}`);let d=Pe(e.tasks);if(d.length>0){l.push("Tasks:");for(let c of d.slice(0,20)){let g=T(c,"title")??T(c,"id")??"task",h=T(c,"state")??"unknown",v=T(c,"type"),a=T(c,"resultSummary");l.push(`- [${h}] ${g}${v?` (${v})`:""}${a?` \u2014 ${X(a,180)}`:""}`)}}let m=Pe(e.gates);if(m.length>0){l.push("Gates:");for(let c of m.slice(0,20)){let g=T(c,"name")??T(c,"gateId")??"gate",h=T(c,"status")??"unknown",v=typeof c.signalCount=="number"?`, signals=${c.signalCount}`:"",a=T(c,"latestCommitSha");l.push(`- ${g}: ${h}${v}${a?`, commit=${a}`:""}`)}}if(i?.length){l.push("Files/components:");for(let c of i.slice(0,50))l.push(`- ${c.type?`${c.type}: `:""}${c.text}`)}return l.join(`
+`)}async function Ce(e,t,n){if(!t.autoRecall)return[];let i=(n??"").trim();if(!i)return[];let r=J(t.recallScope,F(e),t.tagsMatch),s=w(e),l=de(i,t.recallMaxInputChars);try{let d=await x(D(t,e.runtime)),m=ye($e(e,t,"recallQueryTimestampEnabled",!0)),c=await d.recall(t.bank,l,{maxTokens:t.recallBudget,types:t.recallTypes,...m?{queryTimestamp:m}:{},...r?{tags:r.tags,tagsMatch:r.tagsMatch}:{}});s&&await O(s);let g=c?.memories??[];return g.length===0?[]:[{id:"memory:0",title:pt,authority:"memory",priority:50,reason:`Recall for: ${X(i,80)}`,content:g.map(h=>`- ${h.text}`).join(`
+`)}]}catch(d){return me(d)?(s&&await O(s),[]):(s&&await E(s,d),[])}}function Ct(e){return`bobbit-${e.replace(/[^a-zA-Z0-9_.:-]+/g,"-").replace(/^-+|-+$/g,"")}`}function xt(e){if(!e||typeof e!="object")return;let t=e;if(typeof t.content=="string"&&t.content.trim())return t.content.trim();let n=t.reflect_response;if(n&&typeof n=="object"&&typeof n.text=="string")return String(n.text).trim()||void 0}async function St(e,t,n,i,r){let s=n.refreshMentalModel;if(!s)return;let l=w(e),d=Math.max(0,Ae(e,t,"mentalModelRefreshEveryMs",Mt));if(d<=0)return;let m=`${kt}${t.namespace}:${t.bank}:${i}`,c=Date.now(),g=r&&typeof r=="object"&&r.is_stale===!0;if(l){let h=await l.get(m);g=typeof h!="number"||c-h>=d}if(g)try{await s.call(n,t.bank,i),l&&await l.put(m,c)}catch{}}async function It(e,t){if(!t.autoRecall||!$e(e,t,"mentalModelEnabled",!0))return{state:"skipped"};let n=F(e)?.trim();if(!n)return{state:"skipped"};let i=Ct(n),r=J("project",n,"all_strict"),s=[`project:${n}`,"bobbit","kind:mental-model"];try{let l=await x(D(t,e.runtime));if(!l.ensureMentalModel&&!l.getMentalModel)return{state:"skipped"};let d={id:i,name:`Bobbit project memory: ${n}`,sourceQuery:`Current durable project state, key decisions, architecture, conventions, open threads, and recent outcomes for project ${n}.`,tags:s,maxTokens:Math.max(1,Ae(e,t,"mentalModelMaxTokens",ht)),trigger:{fact_types:t.recallTypes,exclude_mental_models:!0,include:null,...r?{tags:Rt(r.tags),tags_match:r.tagsMatch}:{}}},m=l.ensureMentalModel?await l.ensureMentalModel(t.bank,d):void 0,c=l.getMentalModel?await l.getMentalModel(t.bank,i):m,g=xt(c);return g?(await St(e,t,l,i,c),w(e)&&await O(w(e)),{state:"injected",block:{id:"memory:mental-model",title:yt,authority:"memory",priority:60,reason:`Hindsight mental model for project ${n}`,content:g}}):{state:"empty"}}catch(l){let d=w(e);return d&&await E(d,l),{state:"failed"}}}async function De(e,t,n,i){let r=i.bank??t.bank,s=i.namespace??t.namespace,l={...t,bank:r,namespace:s},d=D(t,n),m=await x(d.namespace===s?d:{...d,namespace:s});await m.ensureBank(r),await U(e,m,l),await G(e,m,l);let c=i;await m.retain(r,i.content,q({projectId:i.tags.project},i.tags,!1,{documentId:c.documentId,updateMode:c.updateMode,entities:c.entities,observationScopes:c.observationScopes,timestamp:c.timestamp,metadata:c.metadata}))}async function G(e,t,n){await Me(e,t,n)}async function Le(e,t,n){if(t.retainQueueHealthGate===!1)return!0;try{let i=await x(D(t,n));if((i.health?await i.health():{ok:!0})?.ok===!1)return!1;if(t.retainQueueLlmHealthGate===!0&&i.llmHealth){let s=await i.llmHealth(t.bank);if(s&&typeof s=="object"){let l=s;if(l.ok===!1||Object.values(l).some(m=>m&&typeof m=="object"&&m.ok===!1))return!1}}return!0}catch(i){return await E(e,i),!1}}async function _t(e,t,n){let i=await Q(e);if(i.length===0||!await Le(e,t,n))return;let r=Math.max(0,Math.floor(t.retainQueueDrainMaxPerHook));if(!(r<=0))for(let s=0;s0;s++){let l=i[0];try{await De(e,t,n,l),i.shift(),await H(e,i)}catch(d){await E(e,d);return}}}async function $t(e,t,n){let i=await Q(e);if(i.length===0||!await Le(e,t,n))return;let r=Math.max(0,Math.floor(t.retainQueueShutdownMax||bt)),s=[],l=0;for(let d of i){if(l>=r){s.push(d);continue}try{await De(e,t,n,d),l++}catch{s.push(d)}}await H(e,s)}async function xe(e,t,n,i,r){let s=w(e),l=ne(e,i),d={observationScopes:ie(e),entities:re(e),timestamp:new Date().toISOString()};try{let m=await x(D(t,e.runtime));await m.ensureBank(t.bank),await U(s,m,t),await G(s,m,t),await m.retain(t.bank,n,q(e,l,r,d)),s&&await O(s)}catch(m){s&&(await N(s,se(e,t,n,l,d)),await E(s,m))}}async function te(e,t,n,i,r){let s=await z(n,i);if(s.turns.length===0)return;let l=we(s),d=ne(e,"turn"),m={observationScopes:ie(e),entities:re(e),timestamp:new Date().toISOString()};try{let g=await x(D(t,e.runtime));await g.ensureBank(t.bank),await U(n,g,t),await G(n,g,t),await g.retain(t.bank,l,q(e,d,r,m)),await O(n)}catch(g){await N(n,se(e,t,l,d,m)),await E(n,g)}let c=Re(s.turns,t.retainOverlapTurns);await W(n,i,{turns:[],overlap:c})}var At={async sessionSetup(e){let t=S(e.config);if(!A(t,e.runtime))return{blocks:[]};let n=await L(e,t),i=await It(e,n);return i.state==="injected"&&i.block?{blocks:[i.block]}:{blocks:await Ce(e,n,e.prompt)}},async beforePrompt(e){let t=S(e.config);if(!A(t,e.runtime))return{blocks:[]};let n=await L(e,t);return{blocks:await Ce(e,n,e.prompt)}},async afterTurn(e){let t=S(e.config);if(!A(t,e.runtime)||!t.autoRetain)return{blocks:[]};let n=await L(e,t),i=w(e);i&&await _t(i,n,e.runtime);let r=Et(e),s=ee(e);if(!i||!s)return r&&await xe(e,n,r,"turn",!1),{blocks:[]};let l=await z(i,s);return r&&(l={turns:[...l.turns,{summary:r,ts:Date.now()}],overlap:l.overlap},await W(i,s,l)),ve(l,n.retainEveryNTurns,n.retainMaxDelayMs,Date.now())&&await te(e,n,i,s,!1),{blocks:[]}},async beforeCompact(e){let t=S(e.config);if(!A(t,e.runtime)||!t.autoRetain)return{blocks:[]};let n=await L(e,t),i=w(e),r=ee(e);i&&r&&await te(e,n,i,r,!0);let s=Tt(e);return s&&await xe(e,n,s,"compaction",!0),{blocks:[]}},async sessionShutdown(e){let t=S(e.config);if(!A(t,e.runtime))return{blocks:[]};let n=await L(e,t),i=w(e),r=ee(e);return i&&(t.autoRetain&&r&&await te(e,n,i,r,!1),await $t(i,n,e.runtime)),{blocks:[]}},async goalCompleted(e){let t=S(e.config);if(!A(t,e.runtime)||!t.autoRetain)return{blocks:[]};let n=await L(e,t),i=w(e),r=e.goalId?String(e.goalId):"unknown",s=e.headSha??e.pullRequest?.headSha?String(e.headSha??e.pullRequest?.headSha):"unknown",l=`${vt}${r}:${s}`;if(i&&await i.get(l))return{blocks:[]};if(Z.has(l))return{blocks:[]};Z.add(l);try{i&&await i.put(l,{ts:Date.now(),state:"started"});let d=ne(e,"outcome");d.bobbit="true";let m=Oe(e);m&&(d.pr=m);let c=re(e),g=Pt(e,r,s,c),h={headSha:s};e.branch&&(h.branch=e.branch),e.mergeTarget&&(h.mergeTarget=e.mergeTarget),e.pullRequest?.url&&(h.pullRequestUrl=e.pullRequest.url);let v={documentId:`outcome:${r}`,updateMode:"replace",entities:c,observationScopes:ie(e),timestamp:e.completedAt??new Date().toISOString(),metadata:h};try{let a=await x(D(n,e.runtime));await a.ensureBank(n.bank),await U(i,a,n),await G(i,a,n),await a.retain(n.bank,g,q(e,d,!1,v)),i&&(await O(i),await i.put(l,{ts:Date.now(),state:"retained"}))}catch(a){i&&(await N(i,se(e,n,g,d,v)),await E(i,a),await i.put(l,{ts:Date.now(),state:"queued"}))}}finally{Z.delete(l)}return{blocks:[]}}},jt=At;export{Ke as __setClientFactory,jt as default};
diff --git a/market-packs/hindsight/lib/routes.mjs b/market-packs/hindsight/lib/routes.mjs
index a19139d64..9e2e8bf64 100644
--- a/market-packs/hindsight/lib/routes.mjs
+++ b/market-packs/hindsight/lib/routes.mjs
@@ -1,3 +1,7 @@
import { createRequire as __bbCreateRequire } from 'node:module';
const require = __bbCreateRequire(import.meta.url);
-var F=Object.defineProperty;var q=(e,t,r)=>()=>{if(r)throw r[0];try{return e&&(t=e(e=0)),t}catch(n){throw r=[n],n}};var I=(e,t)=>{for(var r in t)F(e,r,{get:t[r],enumerable:!0})};var $={};I($,{HindsightError:()=>h,createClient:()=>G});function _(e){return e?Object.keys(e).sort().map(t=>`${t}:${e[t]}`):[]}function G(e){let t=e.baseUrl.replace(/\/+$/,""),r=e.namespace&&e.namespace.length>0?e.namespace:Q,n=e.timeoutMs??N,o=encodeURIComponent(r);function i(s){return`${t}/v1/${o}/banks/${encodeURIComponent(s)}`}function c(s){let a={};return s&&(a["Content-Type"]="application/json"),e.apiKey&&(a.Authorization=`Bearer ${e.apiKey}`),a}async function m(s,a,u){let g=new AbortController,d=!1,U=setTimeout(()=>{d=!0,g.abort()},n);try{return await fetch(a,{method:s,headers:c(u!==void 0),body:u!==void 0?JSON.stringify(u):void 0,signal:g.signal})}catch(S){if(d)throw new h("timeout",`Hindsight request timed out after ${n}ms`);let C=S instanceof Error?S.message:String(S);throw new h("network",`Hindsight network error: ${C}`)}finally{clearTimeout(U)}}async function l(s,a,u){let g=await m(s,a,u);if(!g.ok)throw new h("http",`Hindsight HTTP ${g.status} for ${s} ${a}`,g.status);return g}async function f(s,a,u){return await(await l(s,a,u)).json()}return{async health(){try{return{ok:(await m("GET",`${t}/health`)).ok}}catch{return{ok:!1}}},async ensureBank(s){await l("PUT",i(s),{})},async recall(s,a,u){let g=_(u?.tags),d={query:a};return u?.maxTokens!==void 0&&(d.max_tokens=u.maxTokens),g.length>0&&(d.tags=g,d.tags_match=u?.tagsMatch??"any"),{memories:((await f("POST",`${i(s)}/memories/recall`,d)).results??[]).map(C=>({text:C.text,id:C.id,score:C.score}))}},async retain(s,a,u){let g=_(u?.tags),d={content:a};g.length>0&&(d.tags=g),await l("POST",`${i(s)}/memories`,{items:[d],async:!u?.sync})},async reflect(s,a){return{text:(await f("POST",`${i(s)}/reflect`,{query:a})).text}},async listBanks(){return{banks:((await f("GET",`${t}/v1/${o}/banks`)).banks??[]).map(a=>a.bank_id)}}}}var h,N,Q,j=q(()=>{h=class e extends Error{kind;status;constructor(t,r,n){super(r),this.name="HindsightError",this.kind=t,this.status=n,Object.setPrototypeOf(this,e.prototype)}},N=1500,Q="default"});var O=null;function D(e){O=e}async function x(e){return O?O(e):(await Promise.resolve().then(()=>(j(),$))).createClient(e)}var y={mode:"external",bank:"bobbit",namespace:"default",recallScope:"all",autoRecall:!0,autoRetain:!0,recallBudget:1200,timeoutMs:1500};function P(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function p(e,t){if(!P(e))return;let r=e[t];return P(r)&&"default"in r?r.default:r}function E(e){return typeof e=="string"&&e.length>0?e:void 0}function K(e,t){return typeof e=="boolean"?e:t}function L(e,t){return typeof e=="number"&&Number.isFinite(e)?e:t}function Y(e){let t=E(p(e,"externalUrl")),r=E(p(e,"apiKey")),n=p(e,"recallScope")==="project"?"project":"all";return{mode:E(p(e,"mode"))??y.mode,...t?{externalUrl:t}:{},...r?{apiKey:r}:{},bank:E(p(e,"bank"))??y.bank,namespace:E(p(e,"namespace"))??y.namespace,recallScope:n,autoRecall:K(p(e,"autoRecall"),y.autoRecall),autoRetain:K(p(e,"autoRetain"),y.autoRetain),recallBudget:L(p(e,"recallBudget"),y.recallBudget),timeoutMs:L(p(e,"timeoutMs"),y.timeoutMs)}}function V(e){return e.mode==="external"&&typeof e.externalUrl=="string"&&e.externalUrl.trim().length>0}function k(e){return V(e)}function w(e){return{baseUrl:(e.externalUrl??"").replace(/\/+$/,""),...e.apiKey?{apiKey:e.apiKey}:{},namespace:e.namespace,timeoutMs:e.timeoutMs}}var J="retain-queue",A="last-error",v="provider-config:memory";async function B(e){try{let t=await e.get(J);return Array.isArray(t)?t:[]}catch{return[]}}function H(e){if(!P(e))return{ok:!1,errors:["body must be an object"]};let t=[],r={};"mode"in e&&(e.mode==="external"||e.mode==="managed"?r.mode=e.mode:t.push("mode must be 'external' or 'managed'"));for(let n of["externalUrl","apiKey"])if(n in e){let o=e[n];typeof o=="string"?r[n]=o:o===null?r[n]="":t.push(`${n} must be a string`)}for(let n of["bank","namespace"])if(n in e){let o=e[n];typeof o=="string"&&o.trim().length>0?r[n]=o.trim():t.push(`${n} must be a non-empty string`)}"recallScope"in e&&(e.recallScope==="project"||e.recallScope==="all"?r.recallScope=e.recallScope:t.push("recallScope must be 'project' or 'all'"));for(let n of["autoRecall","autoRetain"])n in e&&(typeof e[n]=="boolean"?r[n]=e[n]:t.push(`${n} must be a boolean`));for(let n of["recallBudget","timeoutMs"])if(n in e){let o=e[n];typeof o=="number"&&Number.isFinite(o)&&o>0?r[n]=o:t.push(`${n} must be a positive number`)}return t.length>0?{ok:!1,errors:t}:{ok:!0,value:r}}function M(e){let{apiKey:t,...r}=e;return{...r,apiKeySet:typeof t=="string"&&t.length>0}}async function b(e){let t;try{t=await e.get(v)}catch{t=void 0}return Y({...y,...P(t)?t:{}})}function R(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function T(e){return typeof e=="string"&&e.trim().length>0?e.trim():void 0}async function z(e){return(await B(e)).length}async function W(e){try{return await e.get(A)}catch{return null}}function X(e){return{kind:"manual",...e??{}}}var ne={config:async(e,t)=>{let r=e.host.store,n=(t?.method??"GET").toUpperCase(),o=R(t?.body)&&Object.keys(t.body).length>0;if(n==="GET"||!o){let f=await b(r);return{ok:!0,configured:k(f),config:M(f)}}let i=H(t.body);if(!i.ok)return{ok:!1,error:"CONFIG_INVALID",errors:i.errors??[]};let c={};try{let f=await r.get(v);R(f)&&(c=f)}catch{}let m={...c,...i.value??{}};await r.put(v,m);let l=await b(r);return{ok:!0,configured:k(l),config:M(l)}},status:async e=>{let t=e.host.store,r=await b(t),n=await z(t),o=await W(t),i={configured:k(r),mode:r.mode,bank:r.bank,namespace:r.namespace,recallScope:r.recallScope,autoRecall:r.autoRecall,autoRetain:r.autoRetain,queueDepth:n,...o?{lastError:o}:{}};if(!k(r))return{...i,healthy:!1};let c=!1;try{c=(await(await x(w(r))).health()).ok===!0}catch{c=!1}return{...i,healthy:c}},recall:async(e,t)=>{let r=e.host.store,n=await b(r);if(!k(n))return{configured:!1,memories:[]};let o=R(t?.body)?t.body:{},i=T(o.query)??T(t?.query?.query);if(!i)return{configured:!0,memories:[]};let c=o.scope==="project"||o.scope==="all"?o.scope:n.recallScope,m=T(e.projectId),l=c==="project"&&m?{project:m}:void 0;try{return{configured:!0,memories:(await(await x(w(n))).recall(n.bank,i,{maxTokens:n.recallBudget,...l?{tags:l,tagsMatch:"any"}:{}}))?.memories??[]}}catch(f){return{configured:!0,memories:[],error:String(f?.message??f)}}},retain:async(e,t)=>{let r=e.host.store,n=await b(r);if(!k(n))return{ok:!1,configured:!1};let o=R(t?.body)?t.body:{},i=T(o.content);if(!i)return{ok:!1,configured:!0,error:"content is required"};let c=X(R(o.tags)?o.tags:void 0),m=o.sync===!0;try{let l=await x(w(n));return await l.ensureBank(n.bank),await l.retain(n.bank,i,{tags:c,sync:m}),{ok:!0,configured:!0}}catch(l){return{ok:!1,configured:!0,error:String(l?.message??l)}}},reflect:async(e,t)=>{let r=e.host.store,n=await b(r);if(!k(n))return{configured:!1,text:""};let o=R(t?.body)?t.body:{},i=T(o.prompt);if(!i)return{configured:!0,text:""};try{return{configured:!0,text:(await(await x(w(n))).reflect(n.bank,i))?.text??""}}catch(c){return{configured:!0,text:"",error:String(c?.message??c)}}},banks:async e=>{let t=e.host.store,r=await b(t);if(!k(r))return{configured:!1,banks:[]};try{return{configured:!0,banks:(await(await x(w(r))).listBanks())?.banks??[]}}catch(n){return{configured:!0,banks:[],error:String(n?.message??n)}}}};export{D as __setClientFactory,ne as routes};
+var de=Object.defineProperty;var ge=(e,n,r)=>()=>{if(r)throw r[0];try{return e&&(n=e(e=0)),n}catch(t){throw r=[t],t}};var fe=(e,n)=>{for(var r in n)de(e,r,{get:n[r],enumerable:!0})};var X={};fe(X,{HindsightError:()=>P,createClient:()=>he});function N(e){return e?Object.keys(e).sort().map(n=>`${n}:${e[n]}`):[]}function ye(e){let n={name:e.name,source_query:e.sourceQuery};return e.id&&(n.id=e.id),e.tags&&e.tags.length>0&&(n.tags=[...e.tags]),e.maxTokens!==void 0&&(n.max_tokens=e.maxTokens),e.trigger!==void 0&&(n.trigger=e.trigger),n}function Me(e){let n={...e};return e.sourceQuery!==void 0&&(n.source_query=e.sourceQuery,delete n.sourceQuery),e.maxTokens!==void 0&&(n.max_tokens=e.maxTokens,delete n.maxTokens),n}function J(e){let n={...e};return e.isActive!==void 0&&(n.is_active=e.isActive,delete n.isActive),n}function he(e){let n=e.baseUrl.replace(/\/+$/,""),r=e.namespace&&e.namespace.length>0?e.namespace:pe,t=e.timeoutMs??me,i=encodeURIComponent(r);function c(a){return`${n}/v1/${i}/banks/${encodeURIComponent(a)}`}function u(a){let o={};return a&&(o["Content-Type"]="application/json"),e.apiKey&&(o.Authorization=`Bearer ${e.apiKey}`),o}async function g(a,o,s){let k=new AbortController,l=!1,E=setTimeout(()=>{l=!0,k.abort()},t);try{return await fetch(o,{method:a,headers:u(s!==void 0),body:s!==void 0?JSON.stringify(s):void 0,signal:k.signal})}catch(j){if(l)throw new P("timeout",`Hindsight request timed out after ${t}ms`);let $=j instanceof Error?j.message:String(j);throw new P("network",`Hindsight network error: ${$}`)}finally{clearTimeout(E)}}async function y(a){try{let o=await a.text();if(!o)return"";try{let s=JSON.parse(o);return typeof s?.detail=="string"?s.detail:o}catch{return o}}catch{return""}}async function h(a,o,s){let k=await g(a,o,s);if(!k.ok){let l=await y(k),E=l?`: ${l}`:"";throw new P("http",`Hindsight HTTP ${k.status} for ${a} ${o}${E}`,k.status)}return k}async function f(a,o,s){return await(await h(a,o,s)).json()}async function m(a,o,s){let l=await(await h(a,o,s)).text();return l?JSON.parse(l):void 0}async function b(a){let o=await g("GET",a);if(o.status===404)return null;if(!o.ok){let s=await y(o),k=s?`: ${s}`:"";throw new P("http",`Hindsight HTTP ${o.status} for GET ${a}${k}`,o.status)}return await o.json()}return{async health(){try{return{ok:(await g("GET",`${n}/health`)).ok}}catch{return{ok:!1}}},async ensureBank(a){await h("PUT",c(a),{})},async recall(a,o,s){let k=N(s?.tags),l={query:o};return s?.maxTokens!==void 0&&(l.max_tokens=s.maxTokens),s?.budget!==void 0&&(l.budget=s.budget),s?.types&&s.types.length>0&&(l.types=[...s.types]),s?.include!==void 0&&(l.include=s.include),s?.queryTimestamp!==void 0&&(l.query_timestamp=s.queryTimestamp),s?.trace!==void 0&&(l.trace=s.trace),k.length>0&&(l.tags=k,l.tags_match=s?.tagsMatch??"any"),{memories:((await f("POST",`${c(a)}/memories/recall`,l)).results??[]).map($=>({text:$.text,id:$.id,score:$.score}))}},async retain(a,o,s){let k=N(s?.tags),l={content:o};k.length>0&&(l.tags=k),s?.documentId!==void 0&&(l.document_id=s.documentId),s?.updateMode!==void 0&&(l.update_mode=s.updateMode),s?.entities!==void 0&&(l.entities=s.entities.map(E=>({...E}))),s?.timestamp!==void 0&&(l.timestamp=s.timestamp),s?.observationScopes!==void 0&&(l.observation_scopes=s.observationScopes),s?.metadata!==void 0&&(l.metadata={...s.metadata}),await h("POST",`${c(a)}/memories`,{items:[l],async:!s?.sync})},async reflect(a,o,s){let k=N(s?.tags),l={query:o};s?.budget!==void 0&&(l.budget=s.budget),s?.maxTokens!==void 0&&(l.max_tokens=s.maxTokens),s?.include!==void 0&&(l.include=s.include),s?.responseSchema!==void 0&&(l.response_schema=s.responseSchema),s?.factTypes&&s.factTypes.length>0&&(l.fact_types=[...s.factTypes]),s?.excludeMentalModels!==void 0&&(l.exclude_mental_models=s.excludeMentalModels),s?.excludeMentalModelIds&&s.excludeMentalModelIds.length>0&&(l.exclude_mental_model_ids=[...s.excludeMentalModelIds]),k.length>0&&(l.tags=k,l.tags_match=s?.tagsMatch??"any");let E=await f("POST",`${c(a)}/reflect`,l);return{text:E.text,...E.structured_output!==void 0?{structuredOutput:E.structured_output}:{}}},async listBanks(){return{banks:((await f("GET",`${n}/v1/${i}/banks`)).banks??[]).map(o=>o.bank_id)}},async updateBankConfig(a,o){await h("PATCH",`${c(a)}/config`,{updates:o})},async getMentalModel(a,o){return b(`${c(a)}/mental-models/${encodeURIComponent(o)}`)},async listMentalModels(a){let o=await f("GET",`${c(a)}/mental-models`);return{items:o.items??o.mental_models??[]}},async createMentalModel(a,o){let s=await f("POST",`${c(a)}/mental-models`,ye(o));return{mentalModelId:s.mental_model_id,operationId:s.operation_id}},async ensureMentalModel(a,o){if(o.id){let s=await this.getMentalModel(a,o.id);if(s)return{model:s,created:!1}}try{let s=await this.createMentalModel(a,o),k=o.id??s.mentalModelId;return{model:k?await this.getMentalModel(a,k):null,created:!0,operationId:s.operationId}}catch(s){if(o.id&&s instanceof P&&s.kind==="http"&&s.status===409)return{model:await this.getMentalModel(a,o.id),created:!1};throw s}},async updateMentalModel(a,o,s){return f("PATCH",`${c(a)}/mental-models/${encodeURIComponent(o)}`,Me(s))},async refreshMentalModel(a,o){return{operationId:(await f("POST",`${c(a)}/mental-models/${encodeURIComponent(o)}/refresh`,{})).operation_id}},async clearMentalModel(a,o){return{operationId:(await f("POST",`${c(a)}/mental-models/${encodeURIComponent(o)}/clear`,{})).operation_id}},async getMentalModelHistory(a,o){return{history:(await f("GET",`${c(a)}/mental-models/${encodeURIComponent(o)}/history`)).history??[]}},async listDirectives(a){let o=await f("GET",`${c(a)}/directives`);return{items:o.items??o.directives??[]}},async createDirective(a,o){return f("POST",`${c(a)}/directives`,J(o))},async updateDirective(a,o,s){return f("PATCH",`${c(a)}/directives/${encodeURIComponent(o)}`,J(s))},async deleteDirective(a,o){await h("DELETE",`${c(a)}/directives/${encodeURIComponent(o)}`)},async llmHealth(a){return f("POST",`${c(a)}/health/llm`,{})},async listOperations(a){let o=await f("GET",`${c(a)}/operations`);return{items:o.items??o.operations??[]}},async retryOperation(a,o){return await m("POST",`${c(a)}/operations/${encodeURIComponent(o)}/retry`,{})??{ok:!0}},async deleteOperation(a,o){await h("DELETE",`${c(a)}/operations/${encodeURIComponent(o)}`)},async invalidateMemory(a,o,s){await h("PATCH",`${c(a)}/memories/${encodeURIComponent(o)}`,{state:"invalidated",reason:s})},async getMemoryHistory(a,o){return{history:(await f("GET",`${c(a)}/memories/${encodeURIComponent(o)}/history`)).history??[]}},async deleteMemoryObservations(a,o){await h("DELETE",`${c(a)}/memories/${encodeURIComponent(o)}/observations`)}}}var P,me,pe,z=ge(()=>{P=class e extends Error{kind;status;constructor(n,r,t){super(r),this.name="HindsightError",this.kind=n,this.status=t,Object.setPrototypeOf(this,e.prototype)}},me=4e3,pe="default"});var ke=["observation","world","experience"];function H(e,n,r="any",t){let i=typeof n=="string"&&n.trim().length>0?n.trim():void 0,c=t&&Object.keys(t).length>0?{...t}:void 0;if(e==="project"&&i){let u={...c??{}};return delete u.project,Object.keys(u).length>0?{tags:{project:i,...u},tagsMatch:"all_strict"}:{tags:{project:i},tagsMatch:r}}if(c)return{tags:c,tagsMatch:"any"}}function F(e){return e==="managed"||e==="managed-external-postgres"}var Q=null;function be(e){Q=e}async function C(e){return Q?Q(e):(await Promise.resolve().then(()=>(z(),X))).createClient(e)}var ve="Capture durable, reusable knowledge: user and team preferences, decisions and their rationale, conventions, standards, architecture, and lasting project state. Ignore transient noise \u2014 stack traces, PIDs, timestamps, one-off command output, failed attempts, greetings, and routine per-turn chatter \u2014 unless it records a decision or changes project state.",xe="Observations are stable, durable facts about the people and projects in this bank: preferences, conventions, recurring decisions, architecture, and project state. Consolidate repeated facts into general, reusable statements. Ignore one-off events, ephemeral state, and transient per-turn details.",Re="You are a long-term engineering memory for this team. Ground answers in documented decisions, preferences, conventions, and project state, drawing on consolidated observations rather than raw per-turn chatter. Be direct and precise; do not speculate or resurface transient noise.",we=[{name:"bobbit-coding-agent-recall",content:"Answer for a coding agent. Prefer recent, durable project facts and decisions; cite source facts when present; ignore transient turn noise unless it records a lasting decision or project-state change.",priority:50,tags:["bobbit"]}],p={mode:"external",dataDir:"~/.hindsight",bank:"bobbit",namespace:"default",recallScope:"project",tagsMatch:"any",autoRecall:!0,autoRetain:!0,retainEveryNTurns:5,retainMaxDelayMs:18e5,retainOverlapTurns:2,recallBudget:1200,recallTypes:[...ke],recallQueryTimestampEnabled:!0,mentalModelEnabled:!0,mentalModelMaxTokens:1e3,mentalModelRefreshEveryMs:864e5,mentalModelRecallMaxTokens:1200,directivesEnabled:!1,directiveApplyMode:"disabled",directiveSetVersion:"bobbit-v1",directives:we.map(e=>({...e,tags:e.tags?[...e.tags]:void 0})),retainQueueDrainMaxPerHook:1,retainQueueShutdownMax:10,retainQueueHealthGate:!0,retainQueueLlmHealthGate:!1,retainQueueBatchPauseMs:0,retainMission:ve,observationsMission:xe,reflectMission:Re,recallMaxInputChars:3e3,timeoutMs:4e3};function _(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function d(e,n){if(!_(e))return;let r=e[n];return _(r)&&"default"in r?r.default:r}function v(e){return typeof e=="string"&&e.length>0?e:void 0}function O(e,n){return typeof e=="boolean"?e:n}function R(e,n){return typeof e=="number"&&Number.isFinite(e)?e:n}function G(e){return e==="observation"||e==="world"||e==="experience"}function Te(e,n){if(!Array.isArray(e))return[...n];let r=e.filter(G);return r.length>0?[...new Set(r)]:[...n]}function W(e){return e==="disabled"||e==="bank-wide-explicit-opt-in"||e==="scoped-if-supported"}function Z(e,n){if(!Array.isArray(e))return n.map(t=>({...t,tags:t.tags?[...t.tags]:void 0}));let r=[];for(let t of e)!_(t)||typeof t.name!="string"||t.name.trim().length===0||typeof t.content!="string"||r.push({name:t.name.trim(),content:t.content,...typeof t.priority=="number"&&Number.isFinite(t.priority)?{priority:t.priority}:{},...Array.isArray(t.tags)?{tags:t.tags.filter(i=>typeof i=="string")}:{}});return r.length>0?r:n.map(t=>({...t,tags:t.tags?[...t.tags]:void 0}))}function Ee(e){let n=v(d(e,"externalUrl")),r=v(d(e,"uiUrl")),t=v(d(e,"apiKey")),i=v(d(e,"externalDatabaseUrl")),c=v(d(e,"llmApiKey")),u=d(e,"recallScope")==="all"?"all":"project",g=d(e,"tagsMatch")==="any_strict"?"any_strict":"any",y=Math.max(1,Math.floor(R(d(e,"retainEveryNTurns"),p.retainEveryNTurns))),h=Math.max(0,Math.floor(R(d(e,"retainMaxDelayMs"),p.retainMaxDelayMs))),f=Math.max(0,Math.floor(R(d(e,"retainOverlapTurns"),p.retainOverlapTurns))),m=d(e,"directiveApplyMode"),b=Math.max(0,Math.floor(R(d(e,"retainQueueDrainMaxPerHook"),p.retainQueueDrainMaxPerHook))),a=Math.max(0,Math.floor(R(d(e,"retainQueueShutdownMax"),p.retainQueueShutdownMax))),o=Math.max(0,Math.floor(R(d(e,"retainQueueBatchPauseMs"),p.retainQueueBatchPauseMs)));return{mode:v(d(e,"mode"))??p.mode,...n?{externalUrl:n}:{},...r?{uiUrl:r}:{},...t?{apiKey:t}:{},...i?{externalDatabaseUrl:i}:{},...c?{llmApiKey:c}:{},dataDir:v(d(e,"dataDir"))??p.dataDir,bank:v(d(e,"bank"))??p.bank,namespace:v(d(e,"namespace"))??p.namespace,recallScope:u,tagsMatch:g,autoRecall:O(d(e,"autoRecall"),p.autoRecall),autoRetain:O(d(e,"autoRetain"),p.autoRetain),retainEveryNTurns:y,retainMaxDelayMs:h,retainOverlapTurns:f,recallBudget:R(d(e,"recallBudget"),p.recallBudget),recallTypes:Te(d(e,"recallTypes"),p.recallTypes),recallQueryTimestampEnabled:O(d(e,"recallQueryTimestampEnabled"),p.recallQueryTimestampEnabled),mentalModelEnabled:O(d(e,"mentalModelEnabled"),p.mentalModelEnabled),mentalModelMaxTokens:Math.max(1,Math.floor(R(d(e,"mentalModelMaxTokens"),p.mentalModelMaxTokens))),mentalModelRefreshEveryMs:Math.max(0,Math.floor(R(d(e,"mentalModelRefreshEveryMs"),p.mentalModelRefreshEveryMs))),mentalModelRecallMaxTokens:Math.max(1,Math.floor(R(d(e,"mentalModelRecallMaxTokens"),p.mentalModelRecallMaxTokens))),directivesEnabled:O(d(e,"directivesEnabled"),p.directivesEnabled),directiveApplyMode:W(m)?m:p.directiveApplyMode,directiveSetVersion:v(d(e,"directiveSetVersion"))??p.directiveSetVersion,directives:Z(d(e,"directives"),p.directives),retainQueueDrainMaxPerHook:b,retainQueueShutdownMax:a,retainQueueHealthGate:O(d(e,"retainQueueHealthGate"),p.retainQueueHealthGate),retainQueueLlmHealthGate:O(d(e,"retainQueueLlmHealthGate"),p.retainQueueLlmHealthGate),retainQueueBatchPauseMs:o,retainMission:v(d(e,"retainMission"))??p.retainMission,observationsMission:v(d(e,"observationsMission"))??p.observationsMission,reflectMission:v(d(e,"reflectMission"))??p.reflectMission,recallMaxInputChars:R(d(e,"recallMaxInputChars"),p.recallMaxInputChars),timeoutMs:R(d(e,"timeoutMs"),p.timeoutMs)}}var Pe=1600;function ee(e,n){let r=(e??"").trim(),t=Number.isFinite(n)&&n>0?n:Number.POSITIVE_INFINITY,i=Math.min(t,Pe);return r.length<=i?r:r.slice(0,i)}function te(e){if(!e||typeof e!="object")return!1;let n=e;if(n.kind!=="http"||n.status!==400)return!1;let r=typeof n.message=="string"?n.message.toLowerCase():"";return/\bquery\b/.test(r)?/\btoo\s+(?:long|large|many\s+tokens?)\b/.test(r)?!0:/\b(?:exceeds?|exceeded)\b.*\b(?:max(?:imum)?|limit|tokens?)\b/.test(r)||/\b(?:max(?:imum)?|limit|tokens?)\b.*\b(?:exceeds?|exceeded)\b/.test(r):!1}function I(e,n){return e.mode==="external"?typeof e.externalUrl=="string"&&e.externalUrl.trim().length>0:F(e.mode)?!n||typeof n.baseUrl!="string"||n.baseUrl.trim().length===0?!1:n.status===void 0||n.status==="running":!1}function T(e){return e.mode==="external"?typeof e.externalUrl=="string"&&e.externalUrl.trim().length>0:F(e.mode)}function S(e,n){return{baseUrl:(F(e.mode)?n?.baseUrl??"":e.externalUrl??"").replace(/\/+$/,""),...e.apiKey?{apiKey:e.apiKey}:{},namespace:e.namespace,timeoutMs:e.timeoutMs}}var Ce="retain-queue",U="last-error",L="provider-config:memory",Ie="provider-config:memory:project:",Se="bank-config-applied:";var Oe="directives-applied:";function q(e){return`${Ie}${e}`}async function ne(e){try{let n=await e.get(Ce);return Array.isArray(n)?n:[]}catch{return[]}}async function re(e,n){try{await e.put(U,{message:_e(n),ts:Date.now()})}catch{}}async function A(e){try{await e.put(U,null)}catch{}}function _e(e){return e&&typeof e=="object"&&"message"in e?String(e.message):String(e)}function Ae(e){return JSON.stringify({ns:e.namespace,bank:e.bank,mode:e.directiveApplyMode,version:e.directiveSetVersion,directives:e.directives})}async function ie(e,n,r){if(!r.directivesEnabled||r.directiveApplyMode==="disabled"||typeof n.listDirectives!="function"||typeof n.createDirective!="function")return;let t=`${Oe}${r.namespace}:${r.bank}`,i=Ae(r);if(e)try{if(await e.get(t)===i)return}catch{}try{let c=await n.listDirectives(r.bank);for(let u of r.directives){if(!u.name.startsWith("bobbit-"))continue;let g=c.items.find(h=>h.name===u.name),y={...u,isActive:!0,tags:[...new Set([...u.tags??[],"bobbit"])]};g?.id&&typeof n.updateDirective=="function"?await n.updateDirective(r.bank,g.id,y):await n.createDirective(r.bank,y)}e&&await e.put(t,i)}catch(c){e&&await re(e,c)}}function se(e){if(!_(e))return{ok:!1,errors:["body must be an object"]};let n=[],r={};"mode"in e&&(e.mode==="external"||e.mode==="managed"||e.mode==="managed-external-postgres"?r.mode=e.mode:n.push("mode must be 'external', 'managed', or 'managed-external-postgres'"));for(let t of["externalUrl","apiKey","externalDatabaseUrl","llmApiKey"])if(t in e){let i=e[t];typeof i=="string"?r[t]=i:i===null?r[t]="":n.push(`${t} must be a string`)}if("uiUrl"in e){let t=e.uiUrl;if(t===null||t==="")r.uiUrl="";else if(typeof t=="string"){let i;try{i=new URL(t)}catch{i=void 0}i&&(i.protocol==="http:"||i.protocol==="https:")?r.uiUrl=t:n.push("uiUrl must be an http(s) URL")}else n.push("uiUrl must be a string")}for(let t of["bank","namespace","dataDir"])if(t in e){let i=e[t];typeof i=="string"&&i.trim().length>0?r[t]=i.trim():n.push(`${t} must be a non-empty string`)}if("recallScope"in e&&(e.recallScope==="project"||e.recallScope==="all"?r.recallScope=e.recallScope:n.push("recallScope must be 'project' or 'all'")),"tagsMatch"in e&&(e.tagsMatch==="any"||e.tagsMatch==="any_strict"?r.tagsMatch=e.tagsMatch:n.push("tagsMatch must be 'any' or 'any_strict'")),"recallTypes"in e){let t=e.recallTypes;Array.isArray(t)&&t.length>0&&t.every(G)?r.recallTypes=[...new Set(t)]:n.push("recallTypes must be a non-empty array of 'observation'|'world'|'experience'")}for(let t of["recallQueryTimestampEnabled","mentalModelEnabled","directivesEnabled","retainQueueHealthGate","retainQueueLlmHealthGate"])t in e&&(typeof e[t]=="boolean"?r[t]=e[t]:n.push(`${t} must be a boolean`));if("directiveApplyMode"in e&&(W(e.directiveApplyMode)?r.directiveApplyMode=e.directiveApplyMode:n.push("directiveApplyMode must be 'disabled', 'bank-wide-explicit-opt-in', or 'scoped-if-supported'")),"directiveSetVersion"in e&&(typeof e.directiveSetVersion=="string"&&e.directiveSetVersion.trim().length>0?r.directiveSetVersion=e.directiveSetVersion.trim():n.push("directiveSetVersion must be a non-empty string")),"directives"in e){let t=Z(e.directives,[]);t.length>0?r.directives=t:n.push("directives must be a non-empty list of { name, content }")}for(let t of["retainMission","observationsMission","reflectMission"])t in e&&(typeof e[t]=="string"?r[t]=e[t]:n.push(`${t} must be a string`));for(let t of["autoRecall","autoRetain"])t in e&&(typeof e[t]=="boolean"?r[t]=e[t]:n.push(`${t} must be a boolean`));if("retainEveryNTurns"in e){let t=e.retainEveryNTurns;typeof t=="number"&&Number.isFinite(t)&&t>=1?r.retainEveryNTurns=Math.floor(t):n.push("retainEveryNTurns must be a number >= 1")}if("retainMaxDelayMs"in e){let t=e.retainMaxDelayMs;typeof t=="number"&&Number.isFinite(t)&&t>=0?r.retainMaxDelayMs=Math.floor(t):n.push("retainMaxDelayMs must be a number >= 0 (0 disables the time-based flush)")}if("retainOverlapTurns"in e){let t=e.retainOverlapTurns;typeof t=="number"&&Number.isFinite(t)&&t>=0?r.retainOverlapTurns=Math.floor(t):n.push("retainOverlapTurns must be a number >= 0")}for(let t of["recallBudget","recallMaxInputChars","timeoutMs","mentalModelMaxTokens","mentalModelRecallMaxTokens"])if(t in e){let i=e[t];typeof i=="number"&&Number.isFinite(i)&&i>0?r[t]=i:n.push(`${t} must be a positive number`)}for(let t of["mentalModelRefreshEveryMs","retainQueueDrainMaxPerHook","retainQueueShutdownMax","retainQueueBatchPauseMs"])if(t in e){let i=e[t];typeof i=="number"&&Number.isFinite(i)&&i>=0?r[t]=Math.floor(i):n.push(`${t} must be a number >= 0`)}return n.length>0?{ok:!1,errors:n}:{ok:!0,value:r}}function K(e){if(!_(e))return{ok:!1,errors:["projectOverride must be an object"]};let n=[],r={},t=i=>i===null||i==="";if("recallScope"in e&&!t(e.recallScope)&&(e.recallScope==="project"||e.recallScope==="all"?r.recallScope=e.recallScope:n.push("recallScope must be 'project' or 'all'")),"bank"in e&&!t(e.bank)&&(typeof e.bank=="string"&&e.bank.trim().length>0?r.bank=e.bank.trim():n.push("bank must be a non-empty string")),"tagsMatch"in e&&!t(e.tagsMatch)&&(e.tagsMatch==="any"||e.tagsMatch==="any_strict"?r.tagsMatch=e.tagsMatch:n.push("tagsMatch must be 'any' or 'any_strict'")),"recallBudget"in e&&!t(e.recallBudget)){let i=e.recallBudget;typeof i=="number"&&Number.isFinite(i)&&i>0?r.recallBudget=i:n.push("recallBudget must be a positive number")}if("recallTypes"in e&&e.recallTypes!==null){let i=e.recallTypes;Array.isArray(i)&&i.length>0&&i.every(G)?r.recallTypes=[...new Set(i)]:n.push("recallTypes must be a non-empty array of 'observation'|'world'|'experience'")}return n.length>0?{ok:!1,errors:n}:{ok:!0,value:r}}function D(e){let{apiKey:n,externalDatabaseUrl:r,llmApiKey:t,...i}=e;return{...i,apiKeySet:typeof n=="string"&&n.length>0,externalDatabaseUrlSet:typeof r=="string"&&r.length>0,llmApiKeySet:typeof t=="string"&&t.length>0}}async function oe(e,n){try{let r=await e.get(n);return _(r)?r:void 0}catch{return}}var $e=e=>typeof e=="string"&&e.trim().length>0?e.trim():void 0;async function B(e,n){let r=$e(n);if(!r)return;let t=await oe(e,q(r));if(!t)return;let i=K(t);return i.ok&&i.value&&Object.keys(i.value).length>0?i.value:void 0}async function w(e,n){let r=await oe(e,L)??{},t=await B(e,n)??{};return Ee({...p,...r,...t})}function ae(e){let n={};return e.retainMission&&e.retainMission.trim().length>0&&(n.retain_mission=e.retainMission),e.observationsMission&&e.observationsMission.trim().length>0&&(n.observations_mission=e.observationsMission),e.reflectMission&&e.reflectMission.trim().length>0&&(n.reflect_mission=e.reflectMission),n}function De(e){return JSON.stringify({ns:e.namespace,bank:e.bank,...ae(e)})}async function V(e,n,r){let t=ae(r);if(Object.keys(t).length===0||typeof n.updateBankConfig!="function")return;let i=`${Se}${r.namespace}:${r.bank}`,c=De(r);if(e)try{if(await e.get(i)===c)return}catch{}try{if(await n.updateBankConfig(r.bank,t),e)try{await e.put(i,c)}catch{}}catch(u){e&&await re(e,u)}}function x(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function M(e){return typeof e=="string"&&e.trim().length>0?e.trim():void 0}function ue(e){return typeof e=="number"&&Number.isFinite(e)?String(e):M(e)}function je(e){return x(e)}var Ue=new Set(["observation","world","experience"]);function Le(e){if(!Array.isArray(e))return;let n=e.filter(r=>typeof r=="string"&&Ue.has(r));return n.length>0?[...new Set(n)]:void 0}function Be(e,n){if(e.queryTimestamp===!1||e.queryTimestamp===null)return;let r=M(e.queryTimestamp);if(r)return r;if(n.recallQueryTimestampEnabled!==!1)return new Date().toISOString()}var Ne=["Bobbit coding-agent memory reflection instructions:","- Prefer durable project facts, decisions, conventions, and recent outcomes over transient turn noise.","- Answer for a coding agent working in the repository; be concise, concrete, and cite source facts when Hindsight includes them.","- Do not invent facts that are not supported by memory."].join(`
+`);function Qe(e,n,r){return r.bobbitInstruction===!1||n.directivesEnabled&&n.directiveApplyMode!=="disabled"?e:`${Ne}
+
+User query:
+${e}`}function He(e){if(!Array.isArray(e))return[];let n=[];for(let r of e){if(!x(r))continue;let t=M(r.text);if(!t)continue;let i=M(r.type);n.push({text:t,...i?{type:i}:{}})}return n}function ce(e){return Array.isArray(e)?e.map(M).filter(n=>!!n):[]}function Fe(e){let n=He(e.entities);for(let r of ce(e.files))n.push({text:r,type:"file"});for(let r of ce(e.components))n.push({text:r,type:"component"});return n}function Ge(e,n){let r=x(e.tags)?e.tags:{},t=M(e.goalId),i=ue(e.pr);return{...r,...n?{project:n}:{},...t?{goal:t}:{},...i?{pr:i}:{},bobbit:"true",kind:"outcome"}}function le(e){let n=M(e);return n?[[`project:${n}`]]:void 0}async function qe(e){return(await ne(e)).length}async function Ke(e){try{return await e.get(U)}catch{return null}}function Ve(e,n,r){let t=M(e.projectId),i={kind:"manual"};n==="project"&&t&&(i.project=t);let c=M(e.goalId);c&&(i.goal=c);let u=M(e.sessionId);u&&(i.session=u);let g=M(e.roleName);return g&&(i.agent=g),{...r??{},...i}}async function Y(e,n){if(!n)return{};let r=await w(e),t=await B(e,n);return{globalConfig:D(r),projectOverride:t??null}}var ze={config:async(e,n)=>{let r=e.host.store,t=M(e.projectId),i=(n?.method??"GET").toUpperCase(),c=x(n?.body)&&Object.keys(n.body).length>0;if(i==="GET"||!c){let m=await w(r,t);return{ok:!0,configured:T(m),config:D(m),...await Y(r,t)}}let u=n.body;if("projectOverride"in u){if(!t)return{ok:!1,error:"NO_PROJECT",errors:["projectOverride requires a project context"]};let m=K(u.projectOverride);if(!m.ok)return{ok:!1,error:"CONFIG_INVALID",errors:m.errors??[]};await r.put(q(t),m.value??{});let b=await w(r,t);return{ok:!0,configured:T(b),config:D(b),...await Y(r,t)}}let g=se(u);if(!g.ok)return{ok:!1,error:"CONFIG_INVALID",errors:g.errors??[]};let y={};try{let m=await r.get(L);x(m)&&(y=m)}catch{}let h={...y,...g.value??{}};await r.put(L,h);let f=await w(r,t);return{ok:!0,configured:T(f),config:D(f),...await Y(r,t)}},status:async e=>{let n=e.host.store,r=M(e.projectId),t=await w(n,r),i=r?await B(n,r):void 0,c=await qe(n),u=await Ke(n),g={configured:T(t),mode:t.mode,bank:t.bank,namespace:t.namespace,recallScope:t.recallScope,tagsMatch:t.tagsMatch,autoRecall:t.autoRecall,autoRetain:t.autoRetain,retainEveryNTurns:t.retainEveryNTurns,retainMaxDelayMs:t.retainMaxDelayMs,retainOverlapTurns:t.retainOverlapTurns,projectOverrideActive:!!i,queueDepth:c,externalUrl:t.externalUrl??"",uiUrl:t.uiUrl??"",timeoutMs:t.timeoutMs,recallBudget:t.recallBudget,...u?{lastError:u}:{}};if(!I(t,e.runtime))return{...g,healthy:!1};let y=!1;try{y=(await(await C(S(t,e.runtime))).health()).ok===!0}catch{y=!1}return{...g,healthy:y}},recall:async(e,n)=>{let r=e.host.store,t=await w(r,M(e.projectId));if(!I(t,e.runtime))return{configured:T(t),memories:[]};let i=x(n?.body)?n.body:{},c=M(i.query)??M(n?.query?.query);if(!c)return{configured:!0,memories:[]};let u=i.scope==="project"||i.scope==="all"?i.scope:t.recallScope,g=x(i.tags)?i.tags:void 0,y=H(u,e.projectId,t.tagsMatch,g),h=ee(c,t.recallMaxInputChars);try{let f=await C(S(t,e.runtime)),m=Be(i,t),b=await f.recall(t.bank,h,{maxTokens:t.recallBudget,types:t.recallTypes,...m?{queryTimestamp:m}:{},...y?{tags:y.tags,tagsMatch:y.tagsMatch}:{}});return await A(r),{configured:!0,memories:b?.memories??[]}}catch(f){return te(f)?(await A(r),{configured:!0,memories:[]}):{configured:!0,memories:[],error:String(f?.message??f)}}},retain:async(e,n)=>{let r=e.host.store,t=M(e.projectId),i=await w(r,t);if(!I(i,e.runtime))return{ok:!1,configured:T(i)};let c=x(n?.body)?n.body:{},u=M(c.content);if(!u)return{ok:!1,configured:!0,error:"content is required"};let g=c.scope==="project"||c.scope==="all"?c.scope:i.recallScope,y=x(c.tags)?c.tags:void 0,h=Ve(e,g,y),f=c.sync===!0;try{let m=await C(S(i,e.runtime));return await m.ensureBank(i.bank),await V(r,m,i),await m.retain(i.bank,u,{tags:h,sync:f}),await A(r),{ok:!0,configured:!0}}catch(m){return{ok:!1,configured:!0,error:String(m?.message??m)}}},retain_outcome:async(e,n)=>{let r=e.host.store,t=M(e.projectId),i=await w(r,t);if(!I(i,e.runtime))return{ok:!1,configured:T(i)};let c=x(n?.body)?n.body:{},u=M(c.content);if(!u)return{ok:!1,configured:!0,error:"content is required"};let g=M(c.goalId),y=ue(c.pr),h=g?`outcome:${g}`:y?`outcome:pr:${y}`:void 0;if(!h)return{ok:!1,configured:!0,error:"goalId or pr is required"};let f=M(c.timestamp)??new Date().toISOString(),m=Fe(c);try{let b=await C(S(i,e.runtime));return await b.ensureBank(i.bank),await V(r,b,i),await b.retain(i.bank,u,{tags:Ge(c,t),sync:!1,documentId:h,updateMode:"replace",timestamp:f,...m.length>0?{entities:m}:{},...le(t)?{observationScopes:le(t)}:{}}),await A(r),{ok:!0,configured:!0,documentId:h}}catch(b){return{ok:!1,configured:!0,error:String(b?.message??b),documentId:h}}},reflect:async(e,n)=>{let r=e.host.store,t=await w(r,M(e.projectId));if(!I(t,e.runtime))return{configured:T(t),text:""};let i=x(n?.body)?n.body:{},c=M(i.prompt);if(!c)return{configured:!0,text:""};let u=i.scope==="project"||i.scope==="all"?i.scope:t.recallScope,g=x(i.tags)?i.tags:void 0,y=H(u,e.projectId,t.tagsMatch,g),h=je(i.responseSchema)?i.responseSchema:void 0,f=Le(i.factTypes);try{let m=await C(S(t,e.runtime));await ie(r,m,t);let b={...y?{tags:y.tags,tagsMatch:y.tagsMatch}:{},...h?{responseSchema:h}:{},...f?{factTypes:f}:{},...typeof i.excludeMentalModels=="boolean"?{excludeMentalModels:i.excludeMentalModels}:{},...M(i.budget)?{budget:M(i.budget)}:{},...typeof i.maxTokens=="number"&&Number.isFinite(i.maxTokens)?{maxTokens:i.maxTokens}:{}},a=await m.reflect(t.bank,Qe(c,t,i),Object.keys(b).length>0?b:void 0);return{configured:!0,text:a?.text??"",..."structuredOutput"in(a??{})?{structuredOutput:a.structuredOutput}:{}}}catch(m){return{configured:!0,text:"",error:String(m?.message??m)}}},invalidate:async(e,n)=>{let r=e.host.store,t=await w(r,M(e.projectId));if(!I(t,e.runtime))return{ok:!1,configured:T(t)};let i=x(n?.body)?n.body:{},c=M(i.id),u=M(i.reason);if(!c)return{ok:!1,configured:!0,error:"id is required"};if(!u)return{ok:!1,configured:!0,error:"reason is required"};try{let g=await C(S(t,e.runtime)),y=g.invalidateMemory;if(typeof y!="function")throw new Error("Hindsight client does not support memory invalidation");return await y.call(g,t.bank,c,u),await A(r),{ok:!0,configured:!0,id:c}}catch(g){return{ok:!1,configured:!0,error:String(g?.message??g),id:c}}},banks:async e=>{let n=e.host.store,r=await w(n,M(e.projectId));if(!I(r,e.runtime))return{configured:T(r),banks:[]};try{return{configured:!0,banks:(await(await C(S(r,e.runtime))).listBanks())?.banks??[]}}catch(t){return{configured:!0,banks:[],error:String(t?.message??t)}}}};export{be as __setClientFactory,ze as routes};
diff --git a/market-packs/hindsight/pack.yaml b/market-packs/hindsight/pack.yaml
index ea0c087e7..abb05becb 100644
--- a/market-packs/hindsight/pack.yaml
+++ b/market-packs/hindsight/pack.yaml
@@ -5,16 +5,22 @@ description: >-
tag-scoped bank). Dormant until a Hindsight URL is configured. See
docs/design/hindsight-pack-external.md and docs/design/agent-memory.md.
version: 1.0.0
+# Ships DEFAULT-DISABLED: a fresh server lists this pack in the built-in band but
+# does NOT activate its tools/provider/entrypoints/runtime until the user enables
+# it (via the Marketplace toggle / guided setup) OR it is already configured
+# (a persisted Hindsight config with a non-empty externalUrl or a managed mode).
+# Resolution priority lives in src/server/agent/pack-default-activation.ts.
+defaultDisabled: true
contents:
roles: []
- tools: [] # explicit hindsight_* tools land in G2.3
+ tools: [hindsight] # explicit hindsight_recall/retain/reflect/outcome/invalidate agent tools (P5)
skills: []
- entrypoints: [] # panel + deep link land in G2.3
+ entrypoints: [hindsight-session-menu, hindsight-route] # session-menu launcher + deep-link route for the embedded dashboard (use/view/query) panel
providers: [memory] # → providers/memory.yaml
hooks: []
mcp: []
pi-extensions: []
- runtimes: [] # managed runtime lands in G3
+ runtimes: [hindsight] # P1 managed runtime descriptor → runtimes/hindsight.yaml
workflows: []
provides: []
requires: []
@@ -25,4 +31,4 @@ requires: []
# stays inside the pack root.
routes:
module: lib/routes.mjs
- names: [status, recall, retain, reflect, banks, config]
+ names: [status, recall, retain, retain_outcome, reflect, invalidate, banks, config]
diff --git a/market-packs/hindsight/panels/hindsight-dashboard.yaml b/market-packs/hindsight/panels/hindsight-dashboard.yaml
new file mode 100644
index 000000000..fe07b43a1
--- /dev/null
+++ b/market-packs/hindsight/panels/hindsight-dashboard.yaml
@@ -0,0 +1,13 @@
+id: hindsight.dashboard
+title: Hindsight Memory
+# Singleton EMBEDDED-DASHBOARD panel — the USE/VIEW/QUERY surface opened by the
+# session-menu entry and the #/ext/hindsight deep link (NOT a config surface;
+# configuration lives in the Marketplace). One per session view (no instanceParam),
+# so the deep link needs no params and rehydrates entirely from the config/status
+# routes. `entry` resolves relative to THIS yaml (panels/) and stays inside the
+# pack root; the built bundle lives in the shared lib/ dir and is lazy-imported by
+# the client pack-panels registry, opened via host.ui.openPanel({ panelId }). It
+# reads ONLY through the Host API (host.callRoute config|status) — never a raw
+# fetch — and renders the configured human dashboard `uiUrl` in a SANDBOXED iframe.
+# `uiUrl` is display/open-only: Bobbit JS never dials it.
+entry: ../lib/HindsightDashboardPanel.js
diff --git a/market-packs/hindsight/panels/hindsight-memory.yaml b/market-packs/hindsight/panels/hindsight-memory.yaml
new file mode 100644
index 000000000..507aa0a3c
--- /dev/null
+++ b/market-packs/hindsight/panels/hindsight-memory.yaml
@@ -0,0 +1,10 @@
+id: hindsight.panel
+title: Hindsight Memory
+# Singleton config/status panel — one per session view (no instanceParam), so the
+# deep link needs no params and rehydrates entirely from the config/status routes.
+# `entry` resolves relative to THIS yaml (panels/) and stays inside the pack root;
+# the built bundle lives in the shared lib/ dir and is lazy-imported by the client
+# pack-panels registry, opened via host.ui.openPanel({ panelId }). It reads/writes
+# ONLY through the Host API (host.callRoute config|status|recall) — never a raw
+# fetch, and never a direct host.store config write.
+entry: ../lib/HindsightPanel.js
diff --git a/market-packs/hindsight/providers/memory.yaml b/market-packs/hindsight/providers/memory.yaml
index 7205aad7c..60370d1b1 100644
--- a/market-packs/hindsight/providers/memory.yaml
+++ b/market-packs/hindsight/providers/memory.yaml
@@ -3,23 +3,121 @@ kind: memory
# Lifecycle provider module (bundled, REST client inlined) — resolves to
# market-packs/hindsight/lib/provider.mjs, contained in the pack root.
module: ../lib/provider.mjs
-hooks: [sessionSetup, beforePrompt, afterTurn, beforeCompact, sessionShutdown]
-budget: { maxTokens: 1200, timeoutMs: 1500 }
+# Managed-runtime linkage (P3): names the runtime descriptor (runtimes/hindsight.yaml)
+# the host consults to inject `ctx.runtime` for managed deployment modes. The host
+# resolves the runtime's status + API host port WITHOUT starting Docker; external
+# mode ignores this entirely.
+runtime: hindsight
+hooks: [sessionSetup, beforePrompt, afterTurn, beforeCompact, sessionShutdown, goalCompleted]
+budget: { maxTokens: 1200, timeoutMs: 4500 }
# Activation is additionally gated on `externalUrl` (see `activation` below); the
# provider also enforces the same dormancy gate defensively at runtime.
defaultEnabled: true
config:
- mode: { type: enum, values: [external, managed], default: external } # managed reserved for G3
+ # Deployment mode (P3):
+ # external → operator-supplied data-plane URL (externalUrl). No Docker.
+ # managed → Bobbit runs Hindsight API/web + Postgres (runtime mode managed-postgres).
+ # managed-external-postgres → Bobbit runs Hindsight API/web only; HINDSIGHT_API_DATABASE_URL
+ # from externalDatabaseUrl (runtime mode external-postgres).
+ mode: { type: enum, values: [external, managed, managed-external-postgres], default: external }
externalUrl: { type: string, optional: true }
+ # Optional human-facing Hindsight dashboard URL. Display/open-only ("Open Hindsight
+ # UI") — NEVER dialed by the client and NEVER gates activation (dormancy stays on
+ # externalUrl). Not a secret.
+ uiUrl: { type: string, optional: true }
apiKey: { type: secret, optional: true }
+ # External Postgres connection URL for managed-external-postgres mode. Maps onto
+ # the runtime env HINDSIGHT_API_DATABASE_URL; unused in external/managed modes.
+ externalDatabaseUrl: { type: secret, optional: true }
+ # LLM API key the MANAGED Hindsight API uses for embeddings/summaries. Maps onto
+ # the runtime env HINDSIGHT_API_LLM_API_KEY (the runtime's only user-configured
+ # secret). Required to start a managed mode; unused in external mode. A value set
+ # directly in the global secret store under HINDSIGHT_API_LLM_API_KEY also works.
+ llmApiKey: { type: secret, optional: true }
+ # Host bind-mount path for the fully managed Postgres data (managed mode).
+ dataDir: { type: string, default: ~/.hindsight }
bank: { type: string, default: bobbit }
namespace: { type: string, default: default }
- recallScope: { type: enum, values: [project, all], default: all }
+ # Recall scope: project = this project + shared/global memories (tags_match `any`);
+ # all = every project in the shared bank. Default is now `project` (excludes other
+ # projects' noise while keeping global memories visible).
+ recallScope: { type: enum, values: [project, all], default: project }
+ # Tag-match for PROJECT scope: `any` = project + global (default; never hides
+ # global); `any_strict` = project-only, EXCLUDING global (rare hard-isolation opt-in).
+ tagsMatch: { type: enum, values: [any, any_strict], default: any }
autoRecall: { type: boolean, default: true }
autoRetain: { type: boolean, default: true }
+ # Auto-retain BATCH size: hold compact turn summaries in a durable per-session
+ # buffer and flush ONE aggregate retain (all pending turns) once N have buffered
+ # (cost lever; 1 ≈ every turn). Turns are batched, never sampled — nothing is
+ # dropped. beforeCompact flushes the buffer synchronously regardless.
+ retainEveryNTurns: { type: number, default: 5 }
+ # Hook-observed max age (ms) of the oldest pending buffered turn before it flushes
+ # even when the batch is not full (default 30m). A later hook observing a stale
+ # buffer flushes it — no provider-local timers. 0 disables the time-based flush.
+ retainMaxDelayMs: { type: number, default: 1800000 }
+ # Turn summaries carried forward as bounded OVERLAP context into the next
+ # aggregate (thread continuity). Primary turns are cleared after each flush so the
+ # count always advances; overlap never grows unbounded.
+ retainOverlapTurns: { type: number, default: 2 }
recallBudget: { type: number, default: 1200 }
- timeoutMs: { type: number, default: 1500 }
+ # Hindsight recall `types` filter — bias recall toward consolidated knowledge.
+ recallTypes: { type: list, default: [observation, world, experience] }
+ # Send an ISO `query_timestamp` anchor on recall so relative-time queries resolve
+ # against the current turn/session time.
+ recallQueryTimestampEnabled: { type: boolean, default: true }
+ # Per-project mental model injection at session setup. Creation/refresh is
+ # best-effort; empty/missing models fall back to raw recall.
+ mentalModelEnabled: { type: boolean, default: true }
+ mentalModelMaxTokens: { type: number, default: 1000 }
+ mentalModelRefreshEveryMs: { type: number, default: 86400000 }
+ mentalModelRecallMaxTokens: { type: number, default: 1200 }
+ # Bank-wide directives are disabled by default for the shared bank. Bobbit-specific
+ # reflect behavior should use per-request instructions unless explicitly opted in.
+ directivesEnabled: { type: boolean, default: false }
+ directiveApplyMode: { type: enum, values: [disabled, bank-wide-explicit-opt-in, scoped-if-supported], default: disabled }
+ directiveSetVersion: { type: string, default: bobbit-v1 }
+ directives:
+ type: list
+ default:
+ - name: bobbit-coding-agent-recall
+ content: Answer for a coding agent. Prefer recent, durable project facts and decisions; cite source facts when present; ignore transient turn noise unless it records a lasting decision or project-state change.
+ priority: 50
+ tags: [bobbit]
+ # Queue drains are health-gated and bounded so shutdown cannot burst a large retry
+ # queue into an unhealthy Hindsight daemon.
+ retainQueueDrainMaxPerHook: { type: number, default: 1 }
+ retainQueueShutdownMax: { type: number, default: 10 }
+ retainQueueHealthGate: { type: boolean, default: true }
+ retainQueueLlmHealthGate: { type: boolean, default: false }
+ retainQueueBatchPauseMs: { type: number, default: 0 }
+ # Bank-config missions applied idempotently to the shared bank: steer extraction/
+ # consolidation/reflection toward durable knowledge, away from transient noise.
+ retainMission: { type: string, optional: true }
+ observationsMission: { type: string, optional: true }
+ reflectMission: { type: string, optional: true }
+ # Max characters of the recall QUERY sent to the data plane. The Hindsight recall
+ # API caps queries at 500 TOKENS and returns HTTP 400 ("Query too long") above it.
+ # This char value is an UPPER bound only: clampRecallQuery ALWAYS additionally
+ # enforces a hard token-safe ceiling (~1600 chars ≈ 457 tokens at a conservative
+ # 3.5 chars/token) so the query can never trip the 500-token cap regardless of
+ # this value (even <= 0). Mirrors Hermes' recall_max_input_chars.
+ recallMaxInputChars: { type: number, default: 3000 }
+ timeoutMs: { type: number, default: 4000 }
activation:
- # Host omits the provider entirely (no bridge injection, no per-turn hook calls)
- # until the effective config has a non-empty externalUrl.
+ # EXTERNAL-mode dormancy gate: the host omits the provider entirely (no bridge
+ # injection, no per-turn hook calls) until the effective config has a non-empty
+ # externalUrl.
+ #
+ # MANAGED modes (managed / managed-external-postgres) do NOT use externalUrl. The
+ # host activates the memory provider for these via `activeWhenConfig` below: when
+ # the effective config selects a managed mode, the provider is bridged so it can
+ # consume the injected ctx.runtime. The provider's own isActive(cfg, ctx.runtime)
+ # gate then keeps every hook dormant (no client, no network) until the managed
+ # runtime is actually running — it NEVER starts Docker itself. See
+ # docs/design (P3 modes/consent) §8.
requiresConfig: [externalUrl]
+ # OR escape hatch (deployment-mode linkage): a managed mode activates the provider
+ # regardless of externalUrl. External mode falls through to requiresConfig above.
+ activeWhenConfig:
+ mode: [managed, managed-external-postgres]
diff --git a/market-packs/hindsight/runtime/compose.yaml b/market-packs/hindsight/runtime/compose.yaml
new file mode 100644
index 000000000..4a91ce5b7
--- /dev/null
+++ b/market-packs/hindsight/runtime/compose.yaml
@@ -0,0 +1,51 @@
+# Hindsight runtime compose template (P1 — static, digest-pinned).
+#
+# This is a STATIC template consumed by the runtime manifest layer. It is NEVER
+# executed at this phase. The images are the VERIFIED upstream Hindsight
+# data-plane coordinates (see /Users/aj/Documents/dev/hindsight
+# docker/docker-compose/external-pg/docker-compose.yaml): the first-party API
+# image `ghcr.io/vectorize-io/hindsight` and the pgvector-enabled Postgres image
+# `pgvector/pgvector:pg18`. Both carry a `name:tag@sha256:` pin so the
+# tag stays human-readable while the digest makes the pull reproducible.
+#
+# Managed Bobbit integration only needs the data-plane API (port 8888) plus
+# Postgres — there is NO separate web/control-plane container. The `db` service
+# is defined here so helpers can SELECT services per mode: `managed-postgres`
+# includes `db`; `external-postgres` omits it and supplies
+# HINDSIGHT_API_DATABASE_URL instead.
+name: hindsight
+
+services:
+ # Hindsight data-plane API. Exposes the /v1/{ns}/banks/{bank}/… contract the
+ # client/tools talk to, plus the /health readiness probe the supervisor polls.
+ api:
+ image: ghcr.io/vectorize-io/hindsight:latest@sha256:274704505b2720ac9a5c816c559044c1e8c6b51d47017317ae049ed2952f5ab1
+ restart: unless-stopped
+ environment:
+ HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY}
+ HINDSIGHT_API_DATABASE_URL: ${HINDSIGHT_API_DATABASE_URL}
+ ports:
+ # Data-plane API port is 8888 (verified upstream). Host port is allocated
+ # by the runtime helpers and rendered as HINDSIGHT_API_PORT.
+ - "127.0.0.1:${HINDSIGHT_API_PORT:-8888}:8888"
+
+ # Managed Postgres. Selected only in `managed-postgres` mode. The image is the
+ # pgvector-enabled Postgres 18 build Hindsight requires (the vector extension
+ # is pre-installed) — NOT plain postgres. PGDATA is pinned to the bind-mount
+ # path so the data lands on the visible host directory regardless of the
+ # image's default data layout. The data directory is bind-mounted from the
+ # host; the path comes from the rendered env var HINDSIGHT_DATA_DIR (the
+ # manifest resolves it from the provider `dataDir` config, defaulting to
+ # ~/.hindsight); compose interpolates THAT rendered key — NOT the raw `dataDir`
+ # config field, which is never written to the env file — so a configured custom
+ # data dir is actually honoured here.
+ db:
+ image: pgvector/pgvector:pg18@sha256:c8a919765f2ef63681329fa21021b830cd4d79d1165bdca730dd016014e4da84
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: hindsight
+ POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD}
+ POSTGRES_DB: hindsight
+ PGDATA: /var/lib/postgresql/data
+ volumes:
+ - "${HINDSIGHT_DATA_DIR:-~/.hindsight}/postgres:/var/lib/postgresql/data"
diff --git a/market-packs/hindsight/runtimes/hindsight.yaml b/market-packs/hindsight/runtimes/hindsight.yaml
new file mode 100644
index 000000000..79e01e3dc
--- /dev/null
+++ b/market-packs/hindsight/runtimes/hindsight.yaml
@@ -0,0 +1,122 @@
+# Hindsight runtime manifest (P1 — pure descriptor, no Docker execution).
+#
+# Consumed by the runtime manifest parser/validator (src/server/runtime/manifest.ts)
+# and the pure helper utilities (src/server/runtime/helpers.ts). This file only
+# DECLARES the runtime; it never runs anything. Every field below maps directly
+# onto the parser schema (RuntimeManifest): env refs are exactly one of
+# secret|generate|port|value, port specs use `container`, and generated secrets
+# are declared with `generate: true`.
+id: hindsight
+title: Hindsight
+description: >-
+ Managed Hindsight stack — the data-plane API backed by Postgres. Supports a
+ fully managed Postgres (bind-mounted data dir) or an external/operator-supplied
+ Postgres via a connection URL.
+
+# P3 — Activation policy. `on-enable` means Docker is started ONLY from an explicit
+# user enable/start action in the marketplace/runtime UI or API. Boot, built-in pack
+# discovery, install, update, and provider/session setup must NEVER `compose up`
+# implicitly. (Default for descriptors that omit this is manual / no auto-start.)
+startPolicy: on-enable
+
+# Startup readiness probe (consumed by PackRuntimeSupervisor — NOT the pure P1
+# parser, which ignores unknown top-level keys). After `compose up -d`, the
+# supervisor polls http://127.0.0.1: and only
+# reports the runtime `running` once it returns HTTP 200 (or `unhealthy` on
+# timeout). `port` references a declared ports[].key; `path` is the data-plane
+# /health endpoint on the Hindsight API (port 8888).
+healthcheck:
+ service: api
+ port: HINDSIGHT_API_PORT
+ path: /health
+ intervalMs: 2000
+ startupTimeoutMs: 120000
+
+# P3 — Capability disclosure shown on the enable-card before the runtime starts.
+# Images/services, exposed host ports, and the volume path are derived by the
+# supervisor from this manifest + the selected mode; the fields below carry the
+# human-facing data path default and the memory/trust disclosure copy.
+capabilities:
+ # Effective managed-Postgres bind-mount data path (mode `managed-postgres`).
+ # Mirrors the provider `dataDir` config default; "~/.hindsight" when unset.
+ volumePath: ${dataDir:-~/.hindsight}
+ # First-party Hindsight memory disclosure (design §8).
+ memoryDisclosure: >-
+ Enabling managed Hindsight lets Bobbit store and recall agent conversation
+ summaries and project/goal/session tags in the configured bank. Disabling
+ stops the containers but keeps your data; purge removes the Docker volumes
+ and runtime state.
+ trust: first-party
+
+# Compose template lives one directory up, inside the same pack root. The parser
+# resolves this relative to THIS manifest file and rejects any path that escapes
+# the pack root.
+composeFile: ../runtime/compose.yaml
+
+# Secrets that are generated-and-persisted (idempotently) by the runtime helpers
+# via SecretsStore. These are NOT user-supplied; the LLM API key (below) is the
+# only user-configured secret and is wired through an env `secret:` ref instead.
+secrets:
+ # Managed Postgres password — generated once and persisted via SecretsStore.
+ - key: HINDSIGHT_DB_PASSWORD
+ generate: true
+
+# Host ports allocated via bind :0, persisted, and re-validated on boot by the
+# runtime helpers. `container` is the informational container-side port — the
+# Hindsight data-plane API listens on 8888.
+ports:
+ - key: HINDSIGHT_API_PORT
+ container: 8888
+
+# Base environment shared by all modes. Each value is exactly one ref kind:
+# secret: resolve from a USER-CONFIGURED secret (never generated)
+# generate: resolve from a GENERATED+persisted secret (idempotent)
+# port: resolve from an allocated host port
+# value: literal, with ${var} / ${var:-default} placeholder substitution
+env:
+ # LLM API key MUST come from a configured user secret — not generated.
+ HINDSIGHT_API_LLM_API_KEY:
+ secret: HINDSIGHT_API_LLM_API_KEY
+ HINDSIGHT_API_PORT:
+ port: HINDSIGHT_API_PORT
+
+# Mode-specific argument construction. `managed-postgres` includes the `db`
+# service. `external-postgres` omits `db` and injects a configured connection URL.
+modes:
+ managed-postgres:
+ title: Managed Postgres
+ # Fully managed Postgres. Includes the `db` service with a bind-mounted data
+ # directory; the API connects to it over the compose network.
+ services:
+ - api
+ - db
+ env:
+ # Generated + persisted managed Postgres password.
+ HINDSIGHT_DB_PASSWORD:
+ generate: HINDSIGHT_DB_PASSWORD
+ # Managed data directory for the bind-mounted Postgres volume. Defaults to
+ # ~/.hindsight when `dataDir` is not supplied.
+ HINDSIGHT_DATA_DIR:
+ value: ${dataDir:-~/.hindsight}
+ # Managed DB connection string assembled from the GENERATED password and
+ # the in-compose `db` service hostname. The ${HINDSIGHT_DB_PASSWORD}
+ # placeholder is resolved from the generated secret of the same key.
+ HINDSIGHT_API_DATABASE_URL:
+ value: postgresql://hindsight:${HINDSIGHT_DB_PASSWORD}@db:5432/hindsight
+
+ external-postgres:
+ title: External Postgres
+ # Operator-supplied Postgres. Omits the managed `db` service; the API uses
+ # the externally provided HINDSIGHT_API_DATABASE_URL.
+ services:
+ - api
+ - db
+ omitServices:
+ - db
+ requireEnv:
+ - HINDSIGHT_API_DATABASE_URL
+ env:
+ # Required external connection URL, configured as a user secret. No `db`
+ # service is started in this mode.
+ HINDSIGHT_API_DATABASE_URL:
+ secret: HINDSIGHT_API_DATABASE_URL
diff --git a/market-packs/hindsight/src/dashboard-panel.js b/market-packs/hindsight/src/dashboard-panel.js
new file mode 100644
index 000000000..ec38fcb8d
--- /dev/null
+++ b/market-packs/hindsight/src/dashboard-panel.js
@@ -0,0 +1,277 @@
+// Hindsight pack CLIENT panel — the EMBEDDED DASHBOARD surface (Hindsight
+// surfaces & UI goal; design "Hindsight surfaces & embedded dashboard"). This is
+// the USE/VIEW/QUERY surface opened by the session-menu entry + the
+// `#/ext/hindsight` deep link. It is NOT a configuration surface — configuration
+// lives in the Marketplace inline form + guided wizard.
+//
+// It renders the human Hindsight dashboard (`uiUrl`) inside a SANDBOXED iframe so
+// the user can browse/query memory without leaving Bobbit. The dashboard locally
+// sends no X-Frame-Options/CSP frame headers, so a direct iframe works; a
+// pragmatic load-timeout surfaces a fallback warning when a secured/unreachable
+// deployment refuses to embed.
+//
+// SECURITY + HOST-API INVARIANTS (mirrors panel.js / pr-walkthrough / artifacts):
+// - `uiUrl` is the HUMAN dashboard URL — display/open-ONLY. Bobbit JS NEVER
+// fetches/probes it; the browser only loads it as the iframe `src`. It is
+// resolved ONLY from the redacted status/config routes and is NEVER
+// synthesized from `externalUrl` (the data-plane API URL Bobbit dials).
+// - NO raw fetch for config/status. Both flow through the versioned Host API
+// (`host.callRoute("config"|"status")`). The panel never builds a gateway URL.
+// - NO config form fields here — the entry no longer configures anything. The
+// empty state points the user at the Marketplace (`#/market`).
+// - NO auto-mutation on mount. `render` is a PURE projection; mount kicks only
+// READ calls (`config` GET, `status` GET) once per session.
+// - `lit` is HOST-INJECTED (`{ html, nothing }`) — never imported.
+// - Theme tokens ONLY — no hardcoded palette, no `prefers-color-scheme`.
+
+const asText = (v, d = "") => (v == null ? d : String(v));
+const msgOf = (e) => (e && e.message ? String(e.message) : String(e));
+
+// Production iframe load-timeout. A cross-origin XFO/CSP refusal is not reliably
+// detectable from the parent, so we use a pragmatic timeout after assigning the
+// `src`: no `load` within this window ⇒ surface the embed warning + fallback link.
+const DEFAULT_IFRAME_TIMEOUT_MS = 7000;
+
+// Sandbox flags: scripts + same-origin (the dashboard is a real app), forms,
+// popups (so in-dashboard links can open). Mandatory — never drop the attribute.
+const IFRAME_SANDBOX = "allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox";
+
+// Per-session panel state survives repaints + panel-instance re-creation within a
+// page session (module-closure cache keyed by the bound `__sessionId`).
+const STATE = globalThis.__bobbitHindsightDashboardState || (globalThis.__bobbitHindsightDashboardState = new Map());
+
+function freshEntry() {
+ return {
+ mountKicked: false,
+ loadState: "loading", // loading | ready | error
+ loadError: null,
+ uiUrl: "",
+ externalUrl: "",
+ host: "",
+ // iframe load tracking (deterministic timeout hook for E2E).
+ frameArmedFor: null, // the uiUrl we armed a load-timeout for
+ frameLoaded: false,
+ frameTimedOut: false,
+ frameTimer: null,
+ };
+}
+
+/** Pretty host for the "Embedded dashboard from " copy. Falls back to the
+ * raw URL when it is not a parseable absolute URL. */
+function hostOf(url) {
+ const v = asText(url, "").trim();
+ if (!v) return "";
+ try {
+ return new URL(v).host || v;
+ } catch {
+ return v;
+ }
+}
+
+/** Read the deterministic test timeout hook (tests set a tiny value); production
+ * has no hook and uses {@link DEFAULT_IFRAME_TIMEOUT_MS}. */
+function iframeTimeoutMs(uiUrl = "") {
+ const v = globalThis.__bobbitHindsightIframeTimeoutMs;
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v;
+ const raw = String(uiUrl || "");
+ const rawMatch = raw.match(/[?&](?:amp;)?__bobbit_hindsight_timeout_ms=(\d+)/);
+ if (rawMatch) return Number(rawMatch[1]);
+ try {
+ const fromUrl = Number(new URL(raw).searchParams.get("__bobbit_hindsight_timeout_ms"));
+ if (Number.isFinite(fromUrl) && fromUrl >= 0) return fromUrl;
+ } catch { /* noop */ }
+ return DEFAULT_IFRAME_TIMEOUT_MS;
+}
+
+function forceIframeTimeout(uiUrl = "") {
+ if (globalThis.__bobbitHindsightIframeForceTimeout === true) return true;
+ const raw = String(uiUrl || "");
+ if (/[?&](?:amp;)?__bobbit_hindsight_force_timeout=1(?:&|$)/.test(raw)) return true;
+ try { return new URL(raw).searchParams.get("__bobbit_hindsight_force_timeout") === "1"; } catch { return false; }
+}
+
+export default function createDashboardPanel({ html, nothing }) {
+ const repaint = (host) => {
+ try { host && host.requestRender && host.requestRender(); } catch { /* non-DOM */ }
+ };
+ const get = (key) => STATE.get(key);
+
+ const clearTimer = (entry) => {
+ if (entry && entry.frameTimer) {
+ try { clearTimeout(entry.frameTimer); } catch { /* noop */ }
+ entry.frameTimer = null;
+ }
+ };
+
+ // ── One-shot load: read redacted config + status and resolve the dashboard
+ // URL. NEVER synthesizes uiUrl from externalUrl; NEVER probes uiUrl. ──
+ async function load(host, key) {
+ let config = null;
+ let status = null;
+ let err = null;
+ try {
+ config = await host.callRoute("config", { method: "GET" });
+ } catch (e) {
+ err = msgOf(e);
+ }
+ try {
+ status = await host.callRoute("status", { method: "GET" });
+ } catch (e) {
+ if (!err) err = msgOf(e);
+ }
+ const entry = get(key);
+ if (!entry) return;
+ const cfg = (config && config.config) || {};
+ const uiUrl = asText((status && status.uiUrl) || cfg.uiUrl || (config && config.uiUrl), "").trim();
+ const externalUrl = asText((status && status.externalUrl) || cfg.externalUrl || (config && config.externalUrl), "").trim();
+ entry.uiUrl = uiUrl;
+ entry.externalUrl = externalUrl;
+ entry.host = hostOf(uiUrl);
+ // A read failure only matters when we have nothing to show.
+ if (!uiUrl && err && !config && !status) {
+ entry.loadState = "error";
+ entry.loadError = err;
+ } else {
+ entry.loadState = "ready";
+ entry.loadError = null;
+ }
+ repaint(host);
+ }
+
+ // Arm the deterministic load-timeout for a freshly-resolved uiUrl (once per
+ // distinct url). Side-effect kept out of the iframe template; mirrors panel.js's
+ // mount-kick pattern (guarded so it fires at most once per url).
+ const armFrameTimeout = (host, key, uiUrl) => {
+ const entry = get(key);
+ if (!entry || entry.frameArmedFor === uiUrl) return;
+ clearTimer(entry);
+ entry.frameArmedFor = uiUrl;
+ entry.frameLoaded = false;
+ entry.frameTimedOut = false;
+ const ms = iframeTimeoutMs(uiUrl);
+ entry.frameTimer = setTimeout(() => {
+ const e = get(key);
+ if (!e) return;
+ e.frameTimer = null;
+ if (!e.frameLoaded) { e.frameTimedOut = true; repaint(host); }
+ }, ms);
+ };
+
+ const onFrameLoad = (host, key) => {
+ const entry = get(key);
+ if (!entry) return;
+ if (forceIframeTimeout(entry.uiUrl)) return;
+ clearTimer(entry);
+ entry.frameLoaded = true;
+ entry.frameTimedOut = false;
+ repaint(host);
+ };
+
+ const STYLE = html``;
+
+ const renderEmpty = (entry) => html`
+ ${STYLE}
+
+
+
+
Hindsight dashboard URL is not configured.
+
+ The embedded Hindsight dashboard opens the human UI at your configured
+ dashboard URL. Configure it in the Marketplace to view and query memory
+ without leaving Bobbit.
+
+ ${entry.externalUrl
+ ? html`
The data-plane API URL (${entry.externalUrl}) is configured, but the dashboard UI URL is missing.
` : nothing}`;
+ };
+
+ const renderManagedCard = (entry, host, key) => {
+ const d = entry.draft || draftFromConfig(null);
+ const s = entry.status || {};
+ const rs = s.runtimeStatus;
+ const running = rs === "running" || rs === "unhealthy" || rs === "starting" || s.healthy || entry.runtimePhase === "starting";
+ const reqOk = requiredInputsPresent(entry);
+ // Start launches Docker from the PERSISTED config, so it must be blocked while
+ // the form has unsaved edits (e.g. an external→managed switch + an unsaved LLM
+ // key): an enabled Start there would dial stale persisted server config.
+ const startDisabled = !entry.configured || entry.dirty || !reqOk || !entry.managedConsentAck || entry.runtimePhase === "starting";
+ const pgRow = d.mode === "managed-external-postgres";
+ return html`
+
+
Managed runtime
+
+ Before you start
+
Pressing Start runtime launches local Docker containers — the Hindsight API${pgRow ? "" : " + a Postgres database"} on loopback ports. The first start may pull an image and take ~1–2 min. Nothing runs until you press Start; Stop keeps your data.
+
+
${reqOk ? "✓" : "•"} Required inputs: LLM API key${pgRow ? " + external Postgres URL" : ""} ${reqOk ? "present (saved)" : "missing — set them in Configuration and Save"}
+
${entry.configured && !entry.dirty ? "✓" : "•"} Configuration saved ${!entry.configured ? "— Save first" : entry.dirty ? "— unsaved changes; Save before starting" : ""}
+
+ ${entry.dirty ? html`
Save your changes before starting — Start uses the saved configuration, not your unsaved edits.
` : nothing}
+
+
+
+
+
+
+ ${renderRuntimeProgress(entry)}
+ `;
+ };
+
+ const renderConfigCard = (entry, host, key) => {
+ const d = entry.draft || draftFromConfig(null);
+ const mode = d.mode;
+ const onMode = (e) => setField(host, key, "mode", e.currentTarget.value);
+ const urlVal = asText(d.externalUrl, "").trim();
+ const urlValidity = mode === "external" && urlVal
+ ? html`${isHttpUrl(urlVal) ? "✓ Looks like a valid URL" : "✗ Must be an http(s) URL"}`
+ : nothing;
+ const uiVal = asText(d.uiUrl, "").trim();
+ const uiValidity = uiVal
+ ? html`${isHttpUrl(uiVal) ? "✓ Looks like a valid URL" : "✗ Must be an http(s) URL"}`
+ : nothing;
+ return html`
+
+
+
Configuration
+
+
+
+ ${entry.dirty
+ ? html`
+ You have unsaved changes. Save persists them; Discard reverts to the stored config.
+
+
`;
+ }
+
+ let entry = get(key);
+ if (!entry) { entry = freshEntry(); STATE.set(key, entry); }
+
+ // Mount: kick READ-only loads ONCE per session (never on every repaint,
+ // never a write, never a runtime start). Pure projection thereafter.
+ if (!entry.mountKicked) {
+ entry.mountKicked = true;
+ loadConfig(host, key);
+ loadStatus(host, key);
+ }
+
+ const loadingConfig = entry.configState === "loading" && !entry.draft;
+ const draftMode = (entry.draft && entry.draft.mode) || "external";
+ // Setup guide auto-opens when dormant (first-run); otherwise via the toggle.
+ const setupVisible = !entry.configured || entry.setupOpen;
+ return html`
+ ${STYLE}
+
+ ${(() => {
+ // Prominent header link to the human-facing Hindsight dashboard when a
+ // uiUrl is configured (status echoes it; config carries it before status
+ // loads). This is the "reach the actual Hindsight UI" affordance — it
+ // opens uiUrl in a new tab and is NEVER dialed by Bobbit.
+ const headerUiUrl = asText((entry.status && entry.status.uiUrl) || (entry.config && entry.config.uiUrl), "");
+ return html`
+