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
+
+ + + + + +
Servicesapi, dbHindsight API + managed Postgres
Ports127.0.0.1 (allocated on enable)loopback only
Data~/.hindsightbind-mounted volume
+
→ runtime HINDSIGHT_API_LLM_API_KEY. Forwarded to the local runtime only; never hardcoded.
+

Start progress

+
+
+

+
+
+ +

5 · Recommended defaults explainer

+ + + + + + + +
Data localitylocal / privateMemory stays on your machine unless you point at a shared deployment.
Bankbobbit (shared)One shared, tag-scoped bank. Use an existing bank like hermes only when connecting to one.
Auto-retainon (async)Saved in the background after each turn — no latency cost.
Auto-recallonRelevant memories pulled in at session start and each turn.
Recall scopeall"Have we solved this before, anywhere?"
Timeout4000 msConservative — 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.

+
+
+
+
+
+ +
+ + + + 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.

`:d} + Configure in Marketplace +
+
+
`,_=(e,o,r)=>{let i=e.uiUrl,t=e.frameTimedOut&&!e.frameLoaded;return a` + ${h} +
+
+
+

Hindsight Memory

+

Embedded dashboard from ${e.host||i}

+
+ +
+ ${t?a`
The Hindsight dashboard did not load in-app. It may block embedding or be unreachable — open it in your browser instead.
`:d} + ${e.frameLoaded?a`
If the frame is blank, open externally.
`:d} +
+ +
+
`};return{render(e,o){let r=e&&e.__sessionId||"hindsight-dashboard-default";if(!!!(o&&o.capabilities&&o.capabilities.callRoute&&typeof o.callRoute=="function"))return a`${h}

Hindsight memory is unavailable on this host.

`;let t=s(r);return t||(t=k(),g.set(r,t)),t.mountKicked||(t.mountKicked=!0,x(o,r)),t.loadState==="loading"?a`${h}

Loading Hindsight dashboard…

`:t.uiUrl?(v(o,r,t.uiUrl),_(t,o,r)):T(t)}}}export{S as default}; diff --git a/market-packs/hindsight/lib/HindsightPanel.js b/market-packs/hindsight/lib/HindsightPanel.js new file mode 100644 index 000000000..2b95afd24 --- /dev/null +++ b/market-packs/hindsight/lib/HindsightPanel.js @@ -0,0 +1,339 @@ +var $=i=>i&&i.message?String(i.message):String(i),c=(i,d="")=>i==null?d:String(i),pe=["apiKey","externalDatabaseUrl","llmApiKey"];var H=`${encodeURIComponent("hindsight")}:${encodeURIComponent("hindsight")}`,S="http://localhost:9177",R="http://localhost:19177/banks/hermes?view=data";function B(){try{return globalThis.localStorage&&localStorage.getItem("gateway.url")||globalThis.location?.origin||""}catch{return globalThis.location?.origin||""}}function O(){try{return globalThis.localStorage&&localStorage.getItem("gateway.token")||""}catch{return""}}function T(i){let d=c(i,"").trim();if(!d)return!1;try{let w=new URL(d);return w.protocol==="http:"||w.protocol==="https:"}catch{return!1}}var _=globalThis.__bobbitHindsightPanelState||(globalThis.__bobbitHindsightPanelState=new Map);function ge(){return{mountKicked:!1,configState:"loading",configError:null,config:null,configured:!1,draft:null,secretTouched:{apiKey:!1,externalDatabaseUrl:!1,llmApiKey:!1},touched:{},dirty:!1,saving:!1,saveErrors:[],statusState:"loading",status:null,statusError:null,searchState:"idle",searchResults:[],searchError:null,searchDormant:!1,searchQuery:"",searchScope:"",pollTimer:null,pollTicks:0,logsOpen:!1,logsState:"idle",logs:"",logsError:null,setupOpen:!1,setupProgress:null,setupTesting:!1,managedConsentAck:!1,runtimePhase:"idle",runtimeError:null}}function y(i){let d=i||{};return{mode:c(d.mode,"external"),externalUrl:c(d.externalUrl,""),uiUrl:c(d.uiUrl,""),bank:c(d.bank,"bobbit"),namespace:c(d.namespace,"default"),dataDir:c(d.dataDir,"~/.hindsight"),recallScope:d.recallScope==="all"?"all":"project",autoRecall:d.autoRecall!==!1,autoRetain:d.autoRetain!==!1,recallBudget:c(d.recallBudget,"1200"),timeoutMs:c(d.timeoutMs,"4000"),apiKey:"",externalDatabaseUrl:"",llmApiKey:""}}function he({html:i,nothing:d,renderHeader:w}){let g=e=>{try{e&&e.requestRender&&e.requestRender()}catch{}},u=e=>_.get(e),U=e=>{if(e&&e.pollTimer){try{clearTimeout(e.pollTimer)}catch{}e.pollTimer=null}},v=e=>e==="managed"||e==="managed-external-postgres",C=(e,s)=>{let a=u(s);if(!a||!a.status){a&&U(a);return}let r=a.status,t=v(r.mode),o=a.runtimePhase==="starting"||r.runtimeStatus==="starting"||r.runtimeStatus===void 0&&r.configured&&!r.healthy;if(!(t&&r.configured&&!r.healthy&&o&&a.pollTicks<20)){U(a);return}a.pollTimer||(a.pollTimer=setTimeout(()=>{let l=u(s);l&&(l.pollTimer=null,l.pollTicks+=1,k(e,s,!0))},1500))};function L(e,s){e.config=s&&s.config?s.config:null,e.configured=!!(s&&s.configured),e.dirty?e.draft||(e.draft=y(e.config)):(e.draft=y(e.config),e.secretTouched={apiKey:!1,externalDatabaseUrl:!1,llmApiKey:!1},e.touched={})}async function A(e,s){try{let a=await e.callRoute("config",{method:"GET"}),r=u(s);return r?(L(r,a),r.configState="ready",g(e),!0):!1}catch(a){let r=u(s);return r&&(r.configState="error",r.configError=$(a),g(e)),!1}}async function k(e,s,a=!1){let r=u(s);r&&!a&&(r.statusState=r.status?"ready":"loading");try{let t=await e.callRoute("status",{method:"GET"}),o=u(s);if(!o)return;o.status=t||null,o.statusState="ready",o.statusError=null,o.runtimePhase==="starting"&&t&&(t.healthy||t.runtimeStatus==="running")&&(o.runtimePhase="idle"),C(e,s),g(e)}catch(t){let o=u(s);if(!o)return;o.statusState="error",o.statusError=$(t),g(e)}}let j=(e,s)=>{A(e,s),k(e,s)};function K(e){let s=e.config||{},a=e.draft||{},r=e.touched||{},t={};r.mode&&a.mode!==s.mode&&(t.mode=a.mode);for(let o of["externalUrl","uiUrl","bank","namespace","dataDir"]){if(!r[o])continue;let n=c(a[o],""),l=c(s[o],"");n!==l&&(t[o]=n)}r.recallScope&&a.recallScope!==(s.recallScope==="all"?"all":"project")&&(t.recallScope=a.recallScope);for(let o of["autoRecall","autoRetain"])r[o]&&!!a[o]!=(s[o]!==!1)&&(t[o]=!!a[o]);for(let o of["recallBudget","timeoutMs"]){if(!r[o])continue;let n=Number(a[o]);Number.isFinite(n)&&n>0&&n!==Number(s[o])&&(t[o]=n)}for(let o of pe)e.secretTouched[o]&&(t[o]=c(a[o],""));return t}async function N(e,s){let a=u(s);if(!a||!a.draft||a.saving)return;a.saving=!0,a.saveErrors=[],g(e);let r;try{r=await e.callRoute("config",{method:"GET"})}catch(n){let l=u(s);if(!l)return;l.saving=!1,l.saveErrors=[`Couldn't verify the current configuration before saving: ${$(n)}. Save aborted to avoid overwriting a good config \u2014 try again.`],g(e);return}let t=u(s);if(!t)return;L(t,r);let o=K(t);try{let n=await e.callRoute("config",{method:"POST",body:o}),l=u(s);if(!l)return;if(l.saving=!1,n&&n.ok===!1){l.saveErrors=Array.isArray(n.errors)&&n.errors.length?n.errors:[c(n.error,"Save failed")],g(e);return}l.config=n&&n.config?n.config:l.config,l.configured=!!(n&&n.configured),l.draft=y(l.config),l.secretTouched={apiKey:!1,externalDatabaseUrl:!1,llmApiKey:!1},l.touched={},l.dirty=!1,l.pollTicks=0,k(e,s),g(e)}catch(n){let l=u(s);if(!l)return;l.saving=!1,l.saveErrors=[$(n)],g(e)}}let z=(e,s)=>{let a=u(s);a&&(a.draft=y(a.config),a.secretTouched={apiKey:!1,externalDatabaseUrl:!1,llmApiKey:!1},a.touched={},a.dirty=!1,g(e))};async function E(e,s){let a=u(s);if(a){a.logsState="loading",a.logsError=null,g(e);try{let r=B(),t=await fetch(`${r}/api/pack-runtimes/${H}/logs?tail=200`,{headers:{Authorization:`Bearer ${O()}`}}),o=u(s);if(!o)return;if(!t.ok){o.logsState="error",o.logsError=`HTTP ${t.status}`,g(e);return}let n=await t.json().catch(()=>({}));o.logs=c(n&&n.logs,""),o.logsState="loaded",o.logsError=n&&n.status==="docker-unavailable"?"Docker is not available":null,g(e)}catch(r){let t=u(s);if(!t)return;t.logsState="error",t.logsError=$(r),g(e)}}}let q=(e,s)=>{let a=u(s);a&&(a.logsOpen=!a.logsOpen,g(e),a.logsOpen&&E(e,s))};async function M(e,s,a){let r=u(s);if(r){r.runtimePhase=a==="start"?"starting":"stopping",r.runtimeError=null,a==="start"&&(r.pollTicks=0),g(e);try{let t=B(),o=await fetch(`${t}/api/pack-runtimes/${H}/${a}`,{method:"POST",headers:{Authorization:`Bearer ${O()}`,"Content-Type":"application/json"}}),n=u(s);if(!n)return;if(!o.ok){n.runtimePhase="error",n.runtimeError=`HTTP ${o.status}`,g(e);return}a==="stop"&&(n.runtimePhase="idle"),k(e,s),g(e)}catch(t){let o=u(s);if(!o)return;o.runtimePhase="error",o.runtimeError=$(t),g(e)}}}async function F(e,s){let a=u(s);if(!a)return;let r=c(a.searchQuery,"").trim();if(!r)return;a.searchState="searching",a.searchError=null,g(e);let t=a.searchScope||a.config&&a.config.recallScope||"project";try{let o=await e.callRoute("recall",{method:"POST",body:{query:r,scope:t}}),n=u(s);if(!n)return;if(o&&o.configured===!1)n.searchResults=[],n.searchDormant=!0,n.searchState="empty",n.searchError=null;else if(o&&o.error)n.searchResults=[],n.searchDormant=!1,n.searchState="error",n.searchError=String(o.error);else{let l=o&&Array.isArray(o.memories)?o.memories:[];n.searchResults=l,n.searchDormant=!1,n.searchState=l.length?"results":"empty",n.searchError=null}g(e)}catch(o){let n=u(s);if(!n)return;n.searchState="error",n.searchError=$(o),n.searchResults=[],g(e)}}async function G(e,s){let a=u(s);if(!a||a.setupTesting)return;a.setupTesting=!0,a.setupProgress={connection:"running",recall:"pending"},g(e);try{let t=await e.callRoute("status",{method:"GET"}),o=u(s);if(!o)return;o.status=t||o.status,o.statusState="ready",o.setupProgress={...o.setupProgress,connection:t&&t.healthy?"ok":"fail"},g(e)}catch{let t=u(s);t&&(t.setupProgress={...t.setupProgress,connection:"fail"},g(e))}let r=u(s);if(r){r.setupProgress={...r.setupProgress,recall:"running"},g(e);try{let t=await e.callRoute("recall",{method:"POST",body:{query:"hindsight setup smoke test",scope:"all"}}),o=u(s);if(!o)return;let n=!!t&&t.configured!==!1&&!t.error;o.setupProgress={...o.setupProgress,recall:n?"ok":"fail"},o.setupTesting=!1,g(e)}catch{let t=u(s);t&&(t.setupProgress={...t.setupProgress,recall:"fail"},t.setupTesting=!1,g(e))}}}let b=(e,s,a,r)=>{let t=u(s);!t||!t.draft||(t.draft={...t.draft,[a]:r},t.touched={...t.touched,[a]:!0},t.dirty=!0,g(e))},V=(e,s,a,r)=>{let t=u(s);!t||!t.draft||(t.draft={...t.draft,[a]:r},t.secretTouched={...t.secretTouched,[a]:!0},t.dirty=!0,g(e))},Y=(e,s,a)=>{let r=u(s);if(!r||!r.draft)return;let t={...r.draft},o={...r.touched||{}};a==="external"?(t.mode="external",o.mode=!0):a==="managed"||a==="managed-external-postgres"?(t.mode=a,o.mode=!0):a==="hermes"&&(t.mode="external",t.externalUrl=S,t.bank="hermes",o.mode=!0,o.externalUrl=!0,o.bank=!0,c(t.uiUrl,"").trim()||(t.uiUrl=R,o.uiUrl=!0)),r.draft=t,r.touched=o,r.dirty=!0,g(e)},Q=(e,s)=>{let a=c(e&&e.mode,"external"),r=a==="external"&&c(e&&e.externalUrl,"")===S&&c(e&&e.bank,"")==="hermes";return s==="hermes"?r:s==="external"?a==="external"&&!r:a===s};function X(e){let s=e.status;if(!(s?!!s.configured:!!e.configured))return{state:"dormant",label:"Not configured",hint:"No memory backend configured yet."};let r=s&&s.mode||e.config&&e.config.mode||"external";if(!v(r))return s&&s.healthy?{state:"connected",label:"Connected",hint:"Connected to your Hindsight."}:{state:"unreachable",label:"Unreachable",hint:"Can't reach Hindsight at the configured URL."};let t=s&&s.runtimeStatus;return t==="running"||s&&s.healthy?{state:"running",label:"Running",hint:"Managed runtime is running."}:t==="unhealthy"?{state:"unhealthy",label:"Unhealthy",hint:"Managed runtime is up but not healthy."}:t==="starting"||e.runtimePhase==="starting"?{state:"starting",label:"Starting\u2026",hint:"Managed runtime is starting\u2026"}:{state:"stopped",label:"Stopped",hint:"Managed runtime is stopped."}}let W=e=>{let s=e.config||{},a=s.mode,r=t=>!!s[`${t}Set`];return a==="managed"?r("llmApiKey"):a==="managed-external-postgres"?r("llmApiKey")&&r("externalDatabaseUrl"):!1},x=(e,s,a,r,t={})=>i` + `,P=(e,s,a,r,t,o,n={})=>{let l=r.draft||{},h=r.config&&r.config[`${a}Set`],f=!r.secretTouched[a]&&h?"\u2022\u2022\u2022\u2022 set":n.placeholder||"";return i` + `},D=(e,s,a,r)=>i` + `,J=e=>{let s=e.status||{},a=c(s.mode||e.config&&e.config.mode,"external");return v(a)?"managed runtime (loopback)":c(s.externalUrl||e.config&&e.config.externalUrl,"")||"\u2014"},Z=(e,s,a)=>{let r=X(e),t=e.status||{},o=c(t.mode||e.config&&e.config.mode,"external"),n=Number(t.queueDepth||0),l=c(t.uiUrl||e.config&&e.config.uiUrl,""),h=c(t.timeoutMs!=null?t.timeoutMs:e.config&&e.config.timeoutMs,""),f=c(t.recallBudget!=null?t.recallBudget:e.config&&e.config.recallBudget,""),p=t.lastError,m=p&&typeof p=="object"?c(p.message):c(p,"");return i` +
+
+

Runtime status

+
+ ${r.label} + +
+
+ ${r.hint?i`

${r.hint}

`:d} + ${e.statusState==="error"?i`

${c(e.statusError,"Status unavailable")}

`:i` +
+
Mode
${o}
+
API URL
${J(e)}
+ ${l?i``:d} +
Bank
${c(t.bank||e.config&&e.config.bank,"bobbit")}
+
Namespace
${c(t.namespace||e.config&&e.config.namespace,"default")}
+
Recall scope
${c(t.recallScope||e.config&&e.config.recallScope,"project")==="all"?"all (every project)":"project (this project + shared/global)"}
+
Auto recall / retain
${t.autoRecall===!1?"off":"on"} / ${t.autoRetain===!1?"off":"on"}
+ ${h?i`
Timeout
${h} ms
`:d} + ${f?i`
Recall budget
${f} tokens
`:d} +
+
+ ${n} queued ${n===1?"retain":"retains"} + ${v(o)?i``:d} +
+ ${v(o)&&e.logsOpen?ee(e,s,a):d} + ${m?i`

Last error: ${m}

`:d} + `} +
`},ee=(e,s,a)=>i` +
+
+ Runtime logs (tail ${200}) + +
+ ${e.logsState==="error"?i`

${c(e.logsError,"Logs unavailable")}

`:e.logsState==="loading"&&!e.logs?i`

Loading logs…

`:i` + ${e.logsError?i`

${c(e.logsError)}

`:d} +
${e.logs&&e.logs.length?e.logs:"No logs yet."}
`} +
`,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.
+
+
`,re=e=>{let s=e.setupProgress;if(!s)return d;let a=(r,t)=>i` +
  • + + ${r} + ${t} +
  • `;return i` +
      + ${a("Connection (health probe)",s.connection)} + ${a("Recall smoke test",s.recall)} +
    +

    Auto-retain happens on your next turn — Bobbit never writes a memory unsolicited.

    `},oe=(e,s,a)=>{let r=e.draft||y(null);return i` +
    +
    +

    Set up Hindsight

    + ${e.configured?i``:d} +
    +

    Pick how Hindsight runs. Selecting a managed option only sets the mode — nothing starts until you press Start runtime.

    +
    + ${te.map(t=>{let o=Q(r,t.preset);return i` + `})} +
    + ${ae()} + ${se()} +
    + +
    + ${re(e)} +
    `},ne=e=>{let s=e.status||{},a=s.runtimeStatus,r=e.runtimePhase,t=!!s.healthy,o=(p,m)=>p?"ok":m?"running":"pending",n=r==="starting"||a==="starting"||a==="running"||t,l=a==="running"||a===void 0&&t,h=r==="error",f=(p,m)=>i` +
  • + + ${p} + ${m} +
  • `;return r==="idle"&&!n&&!h?d:i` +
      + ${f("Start runtime",h?"fail":o(n&&(l||a==="starting"),r==="starting"))} + ${f("Health check",h?"fail":o(l,n&&!l))} + ${f("Running",l?"ok":"pending")} +
    + ${e.runtimeError?i`

    ${c(e.runtimeError)}

    `:d}`},ie=(e,s,a)=>{let r=e.draft||y(null),t=e.status||{},o=t.runtimeStatus,n=o==="running"||o==="unhealthy"||o==="starting"||t.healthy||e.runtimePhase==="starting",l=W(e),h=!e.configured||e.dirty||!l||!e.managedConsentAck||e.runtimePhase==="starting",f=r.mode==="managed-external-postgres";return i` +
    +

    Managed runtime

    + +
    + + +
    + ${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."})} + +
    + ${x("Bank","hindsight-bank",r.bank,p=>b(s,a,"bank",p.currentTarget.value),{placeholder:"bobbit"})} + ${x("Namespace","hindsight-namespace",r.namespace,p=>b(s,a,"namespace",p.currentTarget.value),{placeholder:"default"})} +
    + + + +
    + ${D("Auto recall","hindsight-auto-recall",r.autoRecall,p=>b(s,a,"autoRecall",p.currentTarget.checked))} + ${D("Auto retain","hindsight-auto-retain",r.autoRetain,p=>b(s,a,"autoRetain",p.currentTarget.checked))} +
    + +
    + ${x("Recall budget (tokens)","hindsight-recall-budget",r.recallBudget,p=>b(s,a,"recallBudget",p.currentTarget.value),{type:"number"})} + ${x("Timeout (ms)","hindsight-timeout",r.timeoutMs,p=>b(s,a,"timeoutMs",p.currentTarget.value),{type:"number"})} +
    + + ${e.saveErrors&&e.saveErrors.length?i`
      ${e.saveErrors.map(p=>i`
    • ${c(p)}
    • `)}
    `:d} +
    `},le=(e,s)=>{let a=c(e&&e.text,""),r=e&&typeof e.score=="number",t=e&&e.id!=null?String(e.id):"";return i` +
  • +
    ${a}
    +
    + ${r?i`score ${Number(e.score).toFixed(2)}`:d} + ${t?i`${t}`:d} +
    +
  • `},ce=(e,s,a)=>{let r=o=>{o&&o.preventDefault(),F(s,a)},t=e.searchScope||e.config&&e.config.recallScope||"project";return i` +
    +

    Search memory

    +
    + {let n=u(a);n&&(n.searchQuery=o.currentTarget.value)}} + /> + + +
    + ${ue(e)} +
    `},ue=e=>e.searchState==="searching"?i`

    Searching…

    `:e.searchState==="error"?i`

    ${c(e.searchError,"Search failed")}

    `:e.searchState==="empty"?e.searchDormant?i`

    Configure Hindsight to search memory.

    `:i`

    No memories matched.

    `:e.searchState==="results"?i`
      ${e.searchResults.map((s,a)=>le(s,a))}
    `:d,I=i``;return{render(e,s){let a=e&&e.__sessionId||"hindsight-default";if(!!!(s&&s.capabilities&&s.capabilities.callRoute&&typeof s.callRoute=="function"))return i`${I}

    Hindsight memory is unavailable on this host.

    `;let t=u(a);t||(t=ge(),_.set(a,t)),t.mountKicked||(t.mountKicked=!0,A(s,a),k(s,a));let o=t.configState==="loading"&&!t.draft,n=t.draft&&t.draft.mode||"external",l=!t.configured||t.setupOpen;return i` + ${I} +
    + ${(()=>{let h=c(t.status&&t.status.uiUrl||t.config&&t.config.uiUrl,"");return i` +
    +

    Hindsight Memory

    +
    + ${h?i`Open Hindsight UI ↗`:d} + ${t.configured&&!t.setupOpen?i``:d} +
    +
    `})()} + ${Z(t,s,a)} + ${t.configState==="error"?i`

    ${c(t.configError,"Config unavailable")}

    `:o?i`

    Loading configuration…

    `:i` + ${l?oe(t,s,a):d} + ${de(t,s,a)} + ${v(n)?ie(t,s,a):d}`} + ${ce(t,s,a)} +
    `}}}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} + Configure in Marketplace +
    +
    +
    `; + + const renderDashboard = (entry, host, key) => { + const uiUrl = entry.uiUrl; + const showWarning = entry.frameTimedOut && !entry.frameLoaded; + return html` + ${STYLE} +
    +
    +
    +

    Hindsight Memory

    +

    Embedded dashboard from ${entry.host || uiUrl}

    +
    + +
    + ${showWarning + ? html`
    The Hindsight dashboard did not load in-app. It may block embedding or be unreachable — open it in your browser instead.
    ` + : nothing} + ${entry.frameLoaded + ? html`
    If the frame is blank, open externally.
    ` + : nothing} +
    + +
    +
    `; + }; + + return { + render(params, host) { + const key = (params && params.__sessionId) || "hindsight-dashboard-default"; + + // Feature-detect Phase-2 callRoute; degrade gracefully on a Phase-1 host. + const canRoute = !!(host && host.capabilities && host.capabilities.callRoute && typeof host.callRoute === "function"); + if (!canRoute) { + return html`${STYLE}

    Hindsight memory is unavailable on this host.

    `; + } + + let entry = get(key); + if (!entry) { entry = freshEntry(); STATE.set(key, entry); } + + // Mount: kick READ-only loads ONCE per session (never on repaint, never a + // write). Pure projection thereafter. + if (!entry.mountKicked) { + entry.mountKicked = true; + load(host, key); + } + + if (entry.loadState === "loading") { + return html`${STYLE}

    Loading Hindsight dashboard…

    `; + } + + if (!entry.uiUrl) return renderEmpty(entry); + + // uiUrl present — arm the deterministic load-timeout for it (once per url). + armFrameTimeout(host, key, entry.uiUrl); + return renderDashboard(entry, host, key); + }, + }; +} diff --git a/market-packs/hindsight/src/hindsight-client.ts b/market-packs/hindsight/src/hindsight-client.ts index ebb1da026..728885e89 100644 --- a/market-packs/hindsight/src/hindsight-client.ts +++ b/market-packs/hindsight/src/hindsight-client.ts @@ -7,7 +7,7 @@ * (Hindsight 0.8.x) — see docs/design/hindsight-pack-external.md §3. * * Design rules (do not soften — they are pinned by tests/hindsight-client.test.ts): - * - Every method arms an AbortController with `cfg.timeoutMs` (default 1500ms); + * - Every method arms an AbortController with `cfg.timeoutMs` (default 4000ms); * an abort surfaces as `HindsightError{ kind:"timeout" }` thrown WITHIN budget. * - Non-2xx ⇒ `HindsightError{ kind:"http", status }`. * - DNS/connection/socket failure ⇒ `HindsightError{ kind:"network" }`. @@ -40,6 +40,16 @@ export class HindsightError extends Error { } } +export type TagsMatch = "any" | "all" | "any_strict" | "all_strict"; +export type RecallType = "observation" | "world" | "experience"; +export type RecallBudget = "low" | "mid" | "high"; +export type UpdateMode = "replace" | "append"; + +export interface EntityInput { + text: string; + type?: string; +} + export interface RecallMemory { text: string; score?: number; @@ -50,16 +60,136 @@ export interface RecallResult { memories: RecallMemory[]; } +export interface RecallInclude { + entities?: null | Record; + chunks?: null | Record; + source_facts?: null | Record; +} + export interface RecallOptions { maxTokens?: number; + budget?: RecallBudget; tags?: Record; - tagsMatch?: "any" | "all" | "any_strict" | "all_strict"; + tagsMatch?: TagsMatch; + /** Hindsight `types` filter (fact types to recall): biases recall toward + * consolidated `observation`s plus `world`/`experience`. Omitted ⇒ upstream + * default (world + experience). */ + types?: RecallType[]; + /** Optional per-request include block. Omitted by default so `include.chunks` + * stays disabled in Hindsight 0.8.3 unless a caller explicitly opts in. */ + include?: RecallInclude; + /** ISO timestamp anchor for relative-time queries (maps to `query_timestamp`). */ + queryTimestamp?: string; + trace?: boolean; +} + +/** Bank-config mission updates (PATCH …/banks/{bank}/config body `{ updates }`). */ +export interface BankConfigUpdates { + retain_mission?: string; + observations_mission?: string; + reflect_mission?: string; } export interface RetainOptions { tags?: Record; /** When true the upstream extraction runs synchronously (`async:false`). */ sync?: boolean; + documentId?: string; + updateMode?: UpdateMode; + entities?: EntityInput[]; + /** Event timestamp, mapped to item-level `timestamp`. */ + timestamp?: string; + /** Hindsight observation scopes; project scopes are nested, e.g. `[["project:p1"]]`. */ + observationScopes?: string | string[][]; + metadata?: Record; +} + +export interface ReflectOptions { + /** Tag filter applied during reflection (maps to scope on the shared bank). */ + tags?: Record; + tagsMatch?: TagsMatch; + responseSchema?: Record; + factTypes?: RecallType[]; + budget?: RecallBudget; + maxTokens?: number; + include?: RecallInclude; + excludeMentalModels?: boolean; + excludeMentalModelIds?: string[]; +} + +export interface ReflectResult { + text: string; + structuredOutput?: unknown; +} + +export type MentalModelTrigger = Record; + +export interface MentalModel { + id: string; + name?: string; + content?: string; + tags?: string[]; + source_query?: string; + max_tokens?: number; + trigger?: MentalModelTrigger; + last_refreshed_at?: string | null; + reflect_response?: unknown; + is_stale?: boolean; + operation_id?: string; +} + +export interface CreateMentalModelOptions { + id?: string; + name: string; + sourceQuery: string; + tags?: string[]; + maxTokens?: number; + trigger?: MentalModelTrigger; +} + +export interface CreateMentalModelResult { + mentalModelId?: string; + operationId?: string; +} + +export interface EnsureMentalModelResult { + model: MentalModel | null; + created: boolean; + operationId?: string; +} + +export interface Directive { + id: string; + name?: string; + content?: string; + priority?: number; + is_active?: boolean; + tags?: string[]; +} + +export interface DirectiveInput { + name: string; + content: string; + priority?: number; + isActive?: boolean; + tags?: string[]; +} + +export interface OperationRecord { + id: string; + status?: string; + type?: string; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface LlmHealthResponse { + ok?: boolean; + retain?: { ok?: boolean; [key: string]: unknown }; + consolidation?: { ok?: boolean; [key: string]: unknown }; + reflect?: { ok?: boolean; [key: string]: unknown }; + [key: string]: unknown; } export interface HindsightClient { @@ -69,8 +199,29 @@ export interface HindsightClient { recall(bank: string, query: string, opts?: RecallOptions): Promise; /** POST …/memories. Resolves on a 2xx (extraction is async upstream). */ retain(bank: string, content: string, opts?: RetainOptions): Promise; - reflect(bank: string, prompt: string): Promise<{ text: string }>; + reflect(bank: string, prompt: string, opts?: ReflectOptions): Promise; listBanks(): Promise<{ banks: string[] }>; + /** Idempotent bank-config mission update. PATCH …/banks/{bank}/config. */ + updateBankConfig(bank: string, updates: BankConfigUpdates): Promise; + getMentalModel(bank: string, id: string): Promise; + listMentalModels(bank: string): Promise<{ items: MentalModel[] }>; + createMentalModel(bank: string, opts: CreateMentalModelOptions): Promise; + ensureMentalModel(bank: string, opts: CreateMentalModelOptions): Promise; + updateMentalModel(bank: string, id: string, patch: Partial & Record): Promise; + refreshMentalModel(bank: string, id: string): Promise<{ operationId?: string }>; + clearMentalModel(bank: string, id: string): Promise<{ operationId?: string }>; + getMentalModelHistory(bank: string, id: string): Promise<{ history: unknown[] }>; + listDirectives(bank: string): Promise<{ items: Directive[] }>; + createDirective(bank: string, directive: DirectiveInput): Promise; + updateDirective(bank: string, id: string, patch: Partial): Promise; + deleteDirective(bank: string, id: string): Promise; + llmHealth(bank: string): Promise; + listOperations(bank: string): Promise<{ items: OperationRecord[] }>; + retryOperation(bank: string, id: string): Promise; + deleteOperation(bank: string, id: string): Promise; + invalidateMemory(bank: string, id: string, reason: string): Promise; + getMemoryHistory(bank: string, id: string): Promise<{ history: unknown[] }>; + deleteMemoryObservations(bank: string, id: string): Promise; } export interface HindsightClientConfig { @@ -78,11 +229,11 @@ export interface HindsightClientConfig { apiKey?: string; /** Default `default`. */ namespace?: string; - /** Per-request abort budget in ms. Default 1500. */ + /** Per-request abort budget in ms. Default 4000. */ timeoutMs?: number; } -const DEFAULT_TIMEOUT_MS = 1500; +const DEFAULT_TIMEOUT_MS = 4000; const DEFAULT_NAMESPACE = "default"; /** Flatten `{ "project": "abc" }` → `["project:abc"]`, sorted for determinism. */ @@ -93,6 +244,40 @@ function flattenTags(tags?: Record): string[] { .map((k) => `${k}:${tags[k]}`); } +function mentalModelCreateBody(opts: CreateMentalModelOptions): Record { + const body: Record = { + name: opts.name, + source_query: opts.sourceQuery, + }; + if (opts.id) body.id = opts.id; + if (opts.tags && opts.tags.length > 0) body.tags = [...opts.tags]; + if (opts.maxTokens !== undefined) body.max_tokens = opts.maxTokens; + if (opts.trigger !== undefined) body.trigger = opts.trigger; + return body; +} + +function mentalModelPatchBody(patch: Partial & Record): Record { + const body: Record = { ...patch }; + if (patch.sourceQuery !== undefined) { + body.source_query = patch.sourceQuery; + delete body.sourceQuery; + } + if (patch.maxTokens !== undefined) { + body.max_tokens = patch.maxTokens; + delete body.maxTokens; + } + return body; +} + +function directiveBody(input: Partial): Record { + const body: Record = { ...input }; + if (input.isActive !== undefined) { + body.is_active = input.isActive; + delete body.isActive; + } + return body; +} + export function createClient(cfg: HindsightClientConfig): HindsightClient { const baseUrl = cfg.baseUrl.replace(/\/+$/, ""); const namespace = cfg.namespace && cfg.namespace.length > 0 ? cfg.namespace : DEFAULT_NAMESPACE; @@ -141,11 +326,32 @@ export function createClient(cfg: HindsightClientConfig): HindsightClient { } } + /** Best-effort extract the upstream error `detail` (or raw body) so the typed + * HindsightError message carries it — e.g. recall's 400 "Query too long: + * tokens exceeds maximum of 500", which the provider/route soft-skips. Never + * throws; returns "" when the body is empty/unreadable. */ + async function errorDetail(res: Response): Promise { + try { + const text = await res.text(); + if (!text) return ""; + try { + const parsed = JSON.parse(text) as { detail?: unknown }; + return typeof parsed?.detail === "string" ? parsed.detail : text; + } catch { + return text; + } + } catch { + return ""; + } + } + /** Fetch + 2xx assertion; returns the Response for the caller to parse. */ async function request(method: string, url: string, body?: unknown): Promise { const res = await rawFetch(method, url, body); if (!res.ok) { - throw new HindsightError("http", `Hindsight HTTP ${res.status} for ${method} ${url}`, res.status); + const detail = await errorDetail(res); + const suffix = detail ? `: ${detail}` : ""; + throw new HindsightError("http", `Hindsight HTTP ${res.status} for ${method} ${url}${suffix}`, res.status); } return res; } @@ -155,6 +361,23 @@ export function createClient(cfg: HindsightClientConfig): HindsightClient { return (await res.json()) as T; } + async function requestMaybeJson(method: string, url: string, body?: unknown): Promise { + const res = await request(method, url, body); + const text = await res.text(); + return text ? (JSON.parse(text) as T) : undefined; + } + + async function getJsonOrNull(url: string): Promise { + const res = await rawFetch("GET", url); + if (res.status === 404) return null; + if (!res.ok) { + const detail = await errorDetail(res); + const suffix = detail ? `: ${detail}` : ""; + throw new HindsightError("http", `Hindsight HTTP ${res.status} for GET ${url}${suffix}`, res.status); + } + return (await res.json()) as T; + } + return { async health(): Promise<{ ok: boolean }> { // Pure reachability probe: every failure (http, timeout, network) maps to @@ -176,6 +399,11 @@ export function createClient(cfg: HindsightClientConfig): HindsightClient { const tags = flattenTags(opts?.tags); const body: Record = { query }; if (opts?.maxTokens !== undefined) body.max_tokens = opts.maxTokens; + if (opts?.budget !== undefined) body.budget = opts.budget; + if (opts?.types && opts.types.length > 0) body.types = [...opts.types]; + if (opts?.include !== undefined) body.include = opts.include; + if (opts?.queryTimestamp !== undefined) body.query_timestamp = opts.queryTimestamp; + if (opts?.trace !== undefined) body.trace = opts.trace; if (tags.length > 0) { body.tags = tags; body.tags_match = opts?.tagsMatch ?? "any"; @@ -197,6 +425,12 @@ export function createClient(cfg: HindsightClientConfig): HindsightClient { const tags = flattenTags(opts?.tags); const item: Record = { content }; if (tags.length > 0) item.tags = tags; + if (opts?.documentId !== undefined) item.document_id = opts.documentId; + if (opts?.updateMode !== undefined) item.update_mode = opts.updateMode; + if (opts?.entities !== undefined) item.entities = opts.entities.map((e) => ({ ...e })); + if (opts?.timestamp !== undefined) item.timestamp = opts.timestamp; + if (opts?.observationScopes !== undefined) item.observation_scopes = opts.observationScopes; + if (opts?.metadata !== undefined) item.metadata = { ...opts.metadata }; // Hindsight `async` defaults to false (synchronous). `sync:true` ⇒ async:false. await request("POST", `${bankBase(bank)}/memories`, { items: [item], @@ -204,11 +438,25 @@ export function createClient(cfg: HindsightClientConfig): HindsightClient { }); }, - async reflect(bank: string, prompt: string): Promise<{ text: string }> { - const data = await requestJson<{ text: string }>("POST", `${bankBase(bank)}/reflect`, { - query: prompt, - }); - return { text: data.text }; + async reflect(bank: string, prompt: string, opts?: ReflectOptions): Promise { + const tags = flattenTags(opts?.tags); + const body: Record = { query: prompt }; + if (opts?.budget !== undefined) body.budget = opts.budget; + if (opts?.maxTokens !== undefined) body.max_tokens = opts.maxTokens; + if (opts?.include !== undefined) body.include = opts.include; + if (opts?.responseSchema !== undefined) body.response_schema = opts.responseSchema; + if (opts?.factTypes && opts.factTypes.length > 0) body.fact_types = [...opts.factTypes]; + if (opts?.excludeMentalModels !== undefined) body.exclude_mental_models = opts.excludeMentalModels; + if (opts?.excludeMentalModelIds && opts.excludeMentalModelIds.length > 0) body.exclude_mental_model_ids = [...opts.excludeMentalModelIds]; + if (tags.length > 0) { + body.tags = tags; + body.tags_match = opts?.tagsMatch ?? "any"; + } + const data = await requestJson<{ text: string; structured_output?: unknown }>("POST", `${bankBase(bank)}/reflect`, body); + return { + text: data.text, + ...(data.structured_output !== undefined ? { structuredOutput: data.structured_output } : {}), + }; }, async listBanks(): Promise<{ banks: string[] }> { @@ -218,5 +466,112 @@ export function createClient(cfg: HindsightClientConfig): HindsightClient { ); return { banks: (data.banks ?? []).map((b) => b.bank_id) }; }, + + async updateBankConfig(bank: string, updates: BankConfigUpdates): Promise { + // BankConfigUpdate: { updates: { retain_mission, observations_mission, … } }. + await request("PATCH", `${bankBase(bank)}/config`, { updates }); + }, + + async getMentalModel(bank: string, id: string): Promise { + return getJsonOrNull(`${bankBase(bank)}/mental-models/${encodeURIComponent(id)}`); + }, + + async listMentalModels(bank: string): Promise<{ items: MentalModel[] }> { + const data = await requestJson<{ items?: MentalModel[]; mental_models?: MentalModel[] }>("GET", `${bankBase(bank)}/mental-models`); + return { items: data.items ?? data.mental_models ?? [] }; + }, + + async createMentalModel(bank: string, opts: CreateMentalModelOptions): Promise { + const data = await requestJson<{ mental_model_id?: string; operation_id?: string }>( + "POST", + `${bankBase(bank)}/mental-models`, + mentalModelCreateBody(opts), + ); + return { mentalModelId: data.mental_model_id, operationId: data.operation_id }; + }, + + async ensureMentalModel(bank: string, opts: CreateMentalModelOptions): Promise { + if (opts.id) { + const existing = await this.getMentalModel(bank, opts.id); + if (existing) return { model: existing, created: false }; + } + try { + const created = await this.createMentalModel(bank, opts); + const id = opts.id ?? created.mentalModelId; + const model = id ? await this.getMentalModel(bank, id) : null; + return { model, created: true, operationId: created.operationId }; + } catch (err) { + if (opts.id && err instanceof HindsightError && err.kind === "http" && err.status === 409) { + return { model: await this.getMentalModel(bank, opts.id), created: false }; + } + throw err; + } + }, + + async updateMentalModel(bank: string, id: string, patch: Partial & Record): Promise { + return requestJson("PATCH", `${bankBase(bank)}/mental-models/${encodeURIComponent(id)}`, mentalModelPatchBody(patch)); + }, + + async refreshMentalModel(bank: string, id: string): Promise<{ operationId?: string }> { + const data = await requestJson<{ operation_id?: string }>("POST", `${bankBase(bank)}/mental-models/${encodeURIComponent(id)}/refresh`, {}); + return { operationId: data.operation_id }; + }, + + async clearMentalModel(bank: string, id: string): Promise<{ operationId?: string }> { + const data = await requestJson<{ operation_id?: string }>("POST", `${bankBase(bank)}/mental-models/${encodeURIComponent(id)}/clear`, {}); + return { operationId: data.operation_id }; + }, + + async getMentalModelHistory(bank: string, id: string): Promise<{ history: unknown[] }> { + const data = await requestJson<{ history?: unknown[] }>("GET", `${bankBase(bank)}/mental-models/${encodeURIComponent(id)}/history`); + return { history: data.history ?? [] }; + }, + + async listDirectives(bank: string): Promise<{ items: Directive[] }> { + const data = await requestJson<{ items?: Directive[]; directives?: Directive[] }>("GET", `${bankBase(bank)}/directives`); + return { items: data.items ?? data.directives ?? [] }; + }, + + async createDirective(bank: string, directive: DirectiveInput): Promise { + return requestJson("POST", `${bankBase(bank)}/directives`, directiveBody(directive)); + }, + + async updateDirective(bank: string, id: string, patch: Partial): Promise { + return requestJson("PATCH", `${bankBase(bank)}/directives/${encodeURIComponent(id)}`, directiveBody(patch)); + }, + + async deleteDirective(bank: string, id: string): Promise { + await request("DELETE", `${bankBase(bank)}/directives/${encodeURIComponent(id)}`); + }, + + async llmHealth(bank: string): Promise { + return requestJson("POST", `${bankBase(bank)}/health/llm`, {}); + }, + + async listOperations(bank: string): Promise<{ items: OperationRecord[] }> { + const data = await requestJson<{ items?: OperationRecord[]; operations?: OperationRecord[] }>("GET", `${bankBase(bank)}/operations`); + return { items: data.items ?? data.operations ?? [] }; + }, + + async retryOperation(bank: string, id: string): Promise { + return (await requestMaybeJson("POST", `${bankBase(bank)}/operations/${encodeURIComponent(id)}/retry`, {})) ?? { ok: true }; + }, + + async deleteOperation(bank: string, id: string): Promise { + await request("DELETE", `${bankBase(bank)}/operations/${encodeURIComponent(id)}`); + }, + + async invalidateMemory(bank: string, id: string, reason: string): Promise { + await request("PATCH", `${bankBase(bank)}/memories/${encodeURIComponent(id)}`, { state: "invalidated", reason }); + }, + + async getMemoryHistory(bank: string, id: string): Promise<{ history: unknown[] }> { + const data = await requestJson<{ history?: unknown[] }>("GET", `${bankBase(bank)}/memories/${encodeURIComponent(id)}/history`); + return { history: data.history ?? [] }; + }, + + async deleteMemoryObservations(bank: string, id: string): Promise { + await request("DELETE", `${bankBase(bank)}/memories/${encodeURIComponent(id)}/observations`); + }, }; } diff --git a/market-packs/hindsight/src/panel.js b/market-packs/hindsight/src/panel.js new file mode 100644 index 000000000..82fffa516 --- /dev/null +++ b/market-packs/hindsight/src/panel.js @@ -0,0 +1,1184 @@ +// Hindsight pack CLIENT panel — the native config/status surface (Extension +// Platform P4, design docs/design/hindsight-panel-p4-implementation.md + the +// follow-on UX polish docs/design/hindsight-ux-polish-implementation.md). It +// REPLACES E2E-only store-seeding as the user-facing configuration path: a +// theme-compatible panel to pick the deployment mode, configure the data-plane +// (external API URL / optional UI URL / API key / bank / namespace / managed +// data-dir / external Postgres URL / LLM key) and recall/retain toggles, observe a +// runtime status card (state / queue depth / active config / last error), run a +// guided setup walkthrough, explicitly Start/Stop the managed runtime, and search +// memory via recall. +// +// SECURITY + HOST-API INVARIANTS (mirrors pr-walkthrough/artifacts): +// - NO raw fetch for CONFIG/STATUS/RECALL. ALL such data flows through the +// versioned Host API (`host.callRoute("config"|"status"|"recall")`). The panel +// never builds a gateway URL for data and never reaches another pack's +// routes/store. The ONLY raw-gateway seam is the server admin runtime surface +// (`/api/pack-runtimes/:id/{logs,start,stop}`) — read-only logs plus the two +// EXPLICIT user-gesture runtime actions (Start/Stop). See §4.5 of the UX doc. +// - NO direct `host.store` config writes — config persistence goes through the +// `config` route so the server's validation (`validateConfigOverrides`) + +// redaction (`redactConfig`) apply. The panel trusts the route's redaction. +// - Secrets are WRITE-ONLY: the `config` GET surface returns only `*Set` +// booleans (`apiKeySet`/`externalDatabaseUrlSet`/`llmApiKeySet`); the panel +// renders a "set" placeholder and never echoes a stored secret. `uiUrl` is +// NON-secret (echoed verbatim). An untouched secret field is OMITTED from the +// POST body (preserved); an explicit clear sends "". +// - NO auto-mutation on mount. `render` is a PURE projection; mount kicks only +// READ calls (`config` GET, `status` GET) once per session, plus a bounded +// health poll while a managed mode is STARTING. Writes (Save) and runtime +// Start/Stop are user gestures. NOTHING starts Docker on mount, mode-select, +// Refresh, or Save — the ONLY start path is the `hindsight-start-runtime` +// click handler. `retain`/`reflect` are never called. +// - `lit` is HOST-INJECTED (`{ html, nothing, renderHeader }`) — never imported. +// - Theme tokens ONLY — no hardcoded palette, no `prefers-color-scheme`. + +const msgOf = (e) => (e && e.message ? String(e.message) : String(e)); +const asText = (v, d = "") => (v == null ? d : String(v)); +const SECRET_FIELDS = ["apiKey", "externalDatabaseUrl", "llmApiKey"]; +const POLL_INTERVAL_MS = 1500; +const POLL_MAX_TICKS = 20; // bounded ~30s health poll for managed modes coming up. +const LOGS_TAIL = 200; + +// The managed runtime's URL-safe API id (mirrors the server's +// encodePackRuntimeId(packId, runtimeId)). For this first-party pack the +// structural packId and the runtime id are both `hindsight`. +const RUNTIME_API_ID = `${encodeURIComponent("hindsight")}:${encodeURIComponent("hindsight")}`; + +// AJ-baked examples surfaced in copy (UX doc §8). API and UI URLs are DISTINCT — +// never fabricate the UI URL from the API URL (different port/path). +const EX_API_URL = "http://localhost:9177"; +const EX_UI_URL = "http://localhost:19177/banks/hermes?view=data"; + +// Resolve the authed gateway base + bearer for the managed-runtime admin surface +// (`/api/pack-runtimes/:id/{logs,start,stop}`). These are SERVER admin routes (NOT +// pack routes), so they are reached the same way the built-in BgProcessPill reaches +// its own logs route: the panel module runs in the app realm and reads the gateway +// url/token the shell persisted. This is the ONLY raw gateway fetch in the panel and +// is confined to the read-only logs affordance + the two EXPLICIT runtime actions +// (Start/Stop) — all CONFIG/STATUS/RECALL data still flows through the Host API. +function gatewayBase() { + try { + return (globalThis.localStorage && localStorage.getItem("gateway.url")) || globalThis.location?.origin || ""; + } catch { + return globalThis.location?.origin || ""; + } +} +function gatewayToken() { + try { + return (globalThis.localStorage && localStorage.getItem("gateway.token")) || ""; + } catch { + return ""; + } +} + +/** True iff `s` is a non-empty, well-formed http(s) URL. Used for inline + * validation of the external API URL / UI URL fields (display-only; never blocks + * Save — a degraded-but-saved config is valid). */ +function isHttpUrl(s) { + const v = asText(s, "").trim(); + if (!v) return false; + try { + const u = new URL(v); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + +// 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.__bobbitHindsightPanelState || (globalThis.__bobbitHindsightPanelState = new Map()); + +function freshEntry() { + return { + mountKicked: false, + configState: "loading", // loading | ready | error + configError: null, + config: null, // redacted config from `config` GET + configured: false, + draft: null, // editable form values + secretTouched: { apiKey: false, externalDatabaseUrl: false, llmApiKey: false }, + // Non-secret fields the user has ACTUALLY edited this draft session. Save + // builds the POST body ONLY from touched fields (+ touched secrets), so a + // stale-but-untouched field can never clobber a config that changed on the + // server after mount (the headline B2 regression). Reset whenever the draft + // is re-seeded from the persisted config (clean load / save / discard). + touched: {}, + dirty: false, + saving: false, + saveErrors: [], + statusState: "loading", // loading | ready | error + status: null, + statusError: null, + searchState: "idle", // idle | searching | results | empty | error + searchResults: [], + searchError: null, + searchDormant: false, + searchQuery: "", + searchScope: "", // "" → use configured recallScope + pollTimer: null, + pollTicks: 0, + logsOpen: false, + logsState: "idle", // idle | loading | loaded | error + logs: "", + logsError: null, + // ── UX polish additions ── + setupOpen: false, // explicit "Setup guide" toggle (auto-shown when dormant) + setupProgress: null, // { connection, recall } step states for the smoke test + setupTesting: false, + managedConsentAck: false, // managed-runtime consent disclosure acknowledged + runtimePhase: "idle", // idle | starting | stopping | error (explicit Start/Stop) + runtimeError: null, + }; +} + +/** Editable draft seeded from the redacted GET config. Secrets start empty + * (write-only); their "set" state is read from the `*Set` booleans at render. */ +function draftFromConfig(cfg) { + const c = cfg || {}; + return { + mode: asText(c.mode, "external"), + externalUrl: asText(c.externalUrl, ""), + uiUrl: asText(c.uiUrl, ""), + bank: asText(c.bank, "bobbit"), + namespace: asText(c.namespace, "default"), + dataDir: asText(c.dataDir, "~/.hindsight"), + recallScope: c.recallScope === "all" ? "all" : "project", + autoRecall: c.autoRecall !== false, + autoRetain: c.autoRetain !== false, + recallBudget: asText(c.recallBudget, "1200"), + timeoutMs: asText(c.timeoutMs, "4000"), + apiKey: "", + externalDatabaseUrl: "", + llmApiKey: "", + }; +} + +export default function createPanel({ html, nothing, renderHeader }) { + void renderHeader; + + const repaint = (host) => { + try { host && host.requestRender && host.requestRender(); } catch { /* non-DOM */ } + }; + + const get = (key) => STATE.get(key); + + const clearPoll = (entry) => { + if (entry && entry.pollTimer) { + try { clearTimeout(entry.pollTimer); } catch { /* noop */ } + entry.pollTimer = null; + } + }; + + const isManaged = (mode) => mode === "managed" || mode === "managed-external-postgres"; + + // ── Bounded managed-mode health poll: flips the badge to Running when the + // runtime comes up. Runs ONLY while a managed runtime is STARTING (an explicit + // Start was issued, or the runtime reports `starting`); stops on healthy / + // running / external / cap / unmount. A KNOWN-stopped runtime is never polled + // (no churn). Pure reads only — NEVER starts anything. + const maybePoll = (host, key) => { + const entry = get(key); + if (!entry || !entry.status) { entry && clearPoll(entry); return; } + const s = entry.status; + const managed = isManaged(s.mode); + // Only poll while transitioning up: an explicit Start is in flight, OR the + // runtime reports `starting`, OR (legacy host without runtimeStatus) it is + // configured-but-not-healthy. A reported `stopped` runtime is NOT polled. + const transitioning = + entry.runtimePhase === "starting" || + s.runtimeStatus === "starting" || + (s.runtimeStatus === undefined && s.configured && !s.healthy); + const shouldPoll = managed && s.configured && !s.healthy && transitioning && entry.pollTicks < POLL_MAX_TICKS; + if (!shouldPoll) { clearPoll(entry); return; } + if (entry.pollTimer) return; + entry.pollTimer = setTimeout(() => { + const e = get(key); + if (!e) return; + e.pollTimer = null; + e.pollTicks += 1; + loadStatus(host, key, /*fromPoll*/ true); + }, POLL_INTERVAL_MS); + }; + + // ── Loads ──────────────────────────────────────────────────────────────── + // loadConfig is DIRTY-AWARE: it always refreshes `entry.config` (the diff base + // for Save) but only re-seeds the editable draft when the user has NO unsaved + // edits. This is the core of the stale-form fix (UX doc §7 / impl §4.1): + // - clean draft → reseed from the freshly-loaded persisted config (fixes B1). + // - dirty draft → keep the user's edits but still update the diff base so a + // later Save diffs against the LIVE config, never a stale snapshot (fixes B2). + // Dirty-aware hydration shared by loadConfig and the pre-save freshness refresh: + // always refresh `entry.config` (the diff base) but only re-seed the editable + // draft when the user has no unsaved edits. Pure state mutation (no repaint). + function applyLoadedConfig(entry, res) { + entry.config = res && res.config ? res.config : null; + entry.configured = !!(res && res.configured); + if (!entry.dirty) { + entry.draft = draftFromConfig(entry.config); + entry.secretTouched = { apiKey: false, externalDatabaseUrl: false, llmApiKey: false }; + entry.touched = {}; + } else if (!entry.draft) { + // Defensive: never leave the form without a draft to render. + entry.draft = draftFromConfig(entry.config); + } + } + + // Returns true on a successful load, false on failure, so callers (Save) can + // fail-fast instead of proceeding from a stale snapshot. + async function loadConfig(host, key) { + try { + const res = await host.callRoute("config", { method: "GET" }); + const entry = get(key); + if (!entry) return false; + applyLoadedConfig(entry, res); + entry.configState = "ready"; + repaint(host); + return true; + } catch (e) { + const entry = get(key); + if (!entry) return false; + entry.configState = "error"; + entry.configError = msgOf(e); + repaint(host); + return false; + } + } + + async function loadStatus(host, key, fromPoll = false) { + const cur = get(key); + if (cur && !fromPoll) cur.statusState = cur.status ? "ready" : "loading"; + try { + const res = await host.callRoute("status", { method: "GET" }); + const entry = get(key); + if (!entry) return; + entry.status = res || null; + entry.statusState = "ready"; + entry.statusError = null; + // Clear the transient explicit-start phase once the runtime is healthy/up. + if (entry.runtimePhase === "starting" && res && (res.healthy || res.runtimeStatus === "running")) { + entry.runtimePhase = "idle"; + } + maybePoll(host, key); + repaint(host); + } catch (e) { + const entry = get(key); + if (!entry) return; + entry.statusState = "error"; + entry.statusError = msgOf(e); + repaint(host); + } + } + + /** Re-hydrate BOTH config and status from the same trigger so the form and the + * status card can never reflect different load generations (UX doc §7.1). */ + const refreshAll = (host, key) => { + loadConfig(host, key); + loadStatus(host, key); + }; + + /** Build the POST body from ONLY the fields the user actually edited this draft + * session — `entry.touched[f]` for non-secrets, `entry.secretTouched[f]` for + * secrets. An UNTOUCHED field is NEVER sent, even if its (stale) draft value + * differs from the freshly-loaded config: this is the headline B2 fix. Example: + * the panel mounts dormant (externalUrl="", bank="bobbit", timeoutMs=4000), the + * server config changes out-of-band to external/hermes/15000, the user toggles + * ONLY autoRetain and Saves. A diff-everything body would POST the stale + * externalUrl/bank/timeout and clobber the good config; gating on `touched` + * sends just `{ autoRetain }`. Touched fields are still diffed against the live + * config (refreshed by Save first) so an unchanged value is a harmless no-op. */ + function buildSaveBody(entry) { + const cfg = entry.config || {}; + const d = entry.draft || {}; + const t = entry.touched || {}; + const body = {}; + if (t.mode && d.mode !== cfg.mode) body.mode = d.mode; + for (const f of ["externalUrl", "uiUrl", "bank", "namespace", "dataDir"]) { + if (!t[f]) continue; + const cur = asText(d[f], ""); + const orig = asText(cfg[f], ""); + if (cur !== orig) body[f] = cur; + } + if (t.recallScope && d.recallScope !== (cfg.recallScope === "all" ? "all" : "project")) body.recallScope = d.recallScope; + for (const f of ["autoRecall", "autoRetain"]) { + if (t[f] && Boolean(d[f]) !== (cfg[f] !== false)) body[f] = Boolean(d[f]); + } + for (const f of ["recallBudget", "timeoutMs"]) { + if (!t[f]) continue; + const n = Number(d[f]); + if (Number.isFinite(n) && n > 0 && n !== Number(cfg[f])) body[f] = n; + } + for (const f of SECRET_FIELDS) { + if (entry.secretTouched[f]) body[f] = asText(d[f], ""); + } + return body; + } + + async function save(host, key) { + const entry = get(key); + if (!entry || !entry.draft || entry.saving) return; + entry.saving = true; + entry.saveErrors = []; + repaint(host); + // Refresh the diff base from the server BEFORE building the body so a stale + // snapshot can never send keys that overwrite a good config (B2). This MUST be + // fail-fast: if the GET fails we abort with a visible save error rather than + // posting a body diffed against a stale `entry.config`. + let fresh; + try { + fresh = await host.callRoute("config", { method: "GET" }); + } catch (err) { + const eErr = get(key); + if (!eErr) return; + eErr.saving = false; + eErr.saveErrors = [`Couldn't verify the current configuration before saving: ${msgOf(err)}. Save aborted to avoid overwriting a good config — try again.`]; + repaint(host); + return; + } + const e1 = get(key); + if (!e1) return; + applyLoadedConfig(e1, fresh); // dirty-aware: updates diff base, keeps draft. + const body = buildSaveBody(e1); + try { + const res = await host.callRoute("config", { method: "POST", body }); + const e2 = get(key); + if (!e2) return; + e2.saving = false; + if (res && res.ok === false) { + e2.saveErrors = Array.isArray(res.errors) && res.errors.length ? res.errors : [asText(res.error, "Save failed")]; + repaint(host); + return; + } + e2.config = res && res.config ? res.config : e2.config; + e2.configured = !!(res && res.configured); + e2.draft = draftFromConfig(e2.config); + e2.secretTouched = { apiKey: false, externalDatabaseUrl: false, llmApiKey: false }; + e2.touched = {}; + e2.dirty = false; + e2.pollTicks = 0; + loadStatus(host, key); // refresh status after a successful save. + repaint(host); + } catch (err) { + const e2 = get(key); + if (!e2) return; + e2.saving = false; + e2.saveErrors = [msgOf(err)]; + repaint(host); + } + } + + /** Discard unsaved edits: re-seed the draft from the last-loaded persisted + * config and clear the dirty flag (UX doc §7 / impl §4.1.4). */ + const discardEdits = (host, key) => { + const entry = get(key); + if (!entry) return; + entry.draft = draftFromConfig(entry.config); + entry.secretTouched = { apiKey: false, externalDatabaseUrl: false, llmApiKey: false }; + entry.touched = {}; + entry.dirty = false; + repaint(host); + }; + + // ── Managed-runtime logs: a REAL affordance (not static text). Toggles an + // inline view that fetches GET /api/pack-runtimes/:id/logs?tail= from the + // server runtime-logs surface. Read-only; only ever a GET. ── + async function loadLogs(host, key) { + const entry = get(key); + if (!entry) return; + entry.logsState = "loading"; + entry.logsError = null; + repaint(host); + try { + const base = gatewayBase(); + const res = await fetch(`${base}/api/pack-runtimes/${RUNTIME_API_ID}/logs?tail=${LOGS_TAIL}`, { + headers: { Authorization: `Bearer ${gatewayToken()}` }, + }); + const e2 = get(key); + if (!e2) return; + if (!res.ok) { + e2.logsState = "error"; + e2.logsError = `HTTP ${res.status}`; + repaint(host); + return; + } + const data = await res.json().catch(() => ({})); + e2.logs = asText(data && data.logs, ""); + e2.logsState = "loaded"; + e2.logsError = data && data.status === "docker-unavailable" ? "Docker is not available" : null; + repaint(host); + } catch (err) { + const e2 = get(key); + if (!e2) return; + e2.logsState = "error"; + e2.logsError = msgOf(err); + repaint(host); + } + } + + const toggleLogs = (host, key) => { + const entry = get(key); + if (!entry) return; + entry.logsOpen = !entry.logsOpen; + repaint(host); + if (entry.logsOpen) loadLogs(host, key); + }; + + // ── EXPLICIT managed-runtime Start / Stop (the ONLY Docker-starting path). ── + // Both are user gestures; Start is additionally gated in the UI behind the + // consent disclosure + required-inputs check (see `renderManagedCard`). + async function runtimeAction(host, key, action) { + const entry = get(key); + if (!entry) return; + entry.runtimePhase = action === "start" ? "starting" : "stopping"; + entry.runtimeError = null; + if (action === "start") entry.pollTicks = 0; + repaint(host); + try { + const base = gatewayBase(); + const res = await fetch(`${base}/api/pack-runtimes/${RUNTIME_API_ID}/${action}`, { + method: "POST", + headers: { Authorization: `Bearer ${gatewayToken()}`, "Content-Type": "application/json" }, + }); + const e2 = get(key); + if (!e2) return; + if (!res.ok) { + e2.runtimePhase = "error"; + e2.runtimeError = `HTTP ${res.status}`; + repaint(host); + return; + } + if (action === "stop") e2.runtimePhase = "idle"; + // Re-read live status; the bounded poll flips the badge once healthy. + loadStatus(host, key); + repaint(host); + } catch (err) { + const e2 = get(key); + if (!e2) return; + e2.runtimePhase = "error"; + e2.runtimeError = msgOf(err); + repaint(host); + } + } + + async function runSearch(host, key) { + const entry = get(key); + if (!entry) return; + const query = asText(entry.searchQuery, "").trim(); + if (!query) return; + entry.searchState = "searching"; + entry.searchError = null; + repaint(host); + const scope = entry.searchScope || (entry.config && entry.config.recallScope) || "project"; + try { + const res = await host.callRoute("recall", { method: "POST", body: { query, scope } }); + const e2 = get(key); + if (!e2) return; + if (res && res.configured === false) { + e2.searchResults = []; + e2.searchDormant = true; + e2.searchState = "empty"; + e2.searchError = null; + } else if (res && res.error) { + e2.searchResults = []; + e2.searchDormant = false; + e2.searchState = "error"; + e2.searchError = String(res.error); + } else { + const mems = res && Array.isArray(res.memories) ? res.memories : []; + e2.searchResults = mems; + e2.searchDormant = false; + e2.searchState = mems.length ? "results" : "empty"; + e2.searchError = null; + } + repaint(host); + } catch (err) { + const e2 = get(key); + if (!e2) return; + e2.searchState = "error"; + e2.searchError = msgOf(err); + e2.searchResults = []; + repaint(host); + } + } + + // ── Guided setup: connection + recall smoke test (NO retain auto-fire). Pure + // reads through the Host API; renders a per-step progress list. ── + async function runSetupTest(host, key) { + const entry = get(key); + if (!entry || entry.setupTesting) return; + entry.setupTesting = true; + entry.setupProgress = { connection: "running", recall: "pending" }; + repaint(host); + // Step 1 — connection (health probe via status GET). + try { + const st = await host.callRoute("status", { method: "GET" }); + const e2 = get(key); + if (!e2) return; + e2.status = st || e2.status; + e2.statusState = "ready"; + e2.setupProgress = { ...e2.setupProgress, connection: st && st.healthy ? "ok" : "fail" }; + repaint(host); + } catch { + const e2 = get(key); + if (e2) { e2.setupProgress = { ...e2.setupProgress, connection: "fail" }; repaint(host); } + } + // Step 2 — recall smoke (probe query; retain is NEVER auto-fired). + const e3 = get(key); + if (!e3) return; + e3.setupProgress = { ...e3.setupProgress, recall: "running" }; + repaint(host); + try { + const res = await host.callRoute("recall", { method: "POST", body: { query: "hindsight setup smoke test", scope: "all" } }); + const e4 = get(key); + if (!e4) return; + const ok = !!res && res.configured !== false && !res.error; + e4.setupProgress = { ...e4.setupProgress, recall: ok ? "ok" : "fail" }; + e4.setupTesting = false; + repaint(host); + } catch { + const e4 = get(key); + if (e4) { e4.setupProgress = { ...e4.setupProgress, recall: "fail" }; e4.setupTesting = false; repaint(host); } + } + } + + // ── Field mutators (local draft only; never touches host.store). ── + const setField = (host, key, field, value) => { + const entry = get(key); + if (!entry || !entry.draft) return; + entry.draft = { ...entry.draft, [field]: value }; + entry.touched = { ...entry.touched, [field]: true }; + entry.dirty = true; + repaint(host); + }; + const setSecret = (host, key, field, value) => { + const entry = get(key); + if (!entry || !entry.draft) return; + entry.draft = { ...entry.draft, [field]: value }; + entry.secretTouched = { ...entry.secretTouched, [field]: true }; + entry.dirty = true; + repaint(host); + }; + + /** Apply a deployment preset from the setup chooser (local draft only). The + * Hermes-local preset bakes AJ's API URL + bank + UI URL. NEVER starts Docker — + * managed presets only set the mode; Start stays an explicit later gesture. */ + const applyDeploymentPreset = (host, key, preset) => { + const entry = get(key); + if (!entry || !entry.draft) return; + const patch = { ...entry.draft }; + const touched = { ...(entry.touched || {}) }; + if (preset === "external") { + patch.mode = "external"; + touched.mode = true; + } else if (preset === "managed" || preset === "managed-external-postgres") { + patch.mode = preset; + touched.mode = true; + } else if (preset === "hermes") { + patch.mode = "external"; + patch.externalUrl = EX_API_URL; + patch.bank = "hermes"; + touched.mode = true; + touched.externalUrl = true; + touched.bank = true; + if (!asText(patch.uiUrl, "").trim()) { patch.uiUrl = EX_UI_URL; touched.uiUrl = true; } + } + entry.draft = patch; + entry.touched = touched; + entry.dirty = true; + repaint(host); + }; + + /** Which deployment preset the current draft matches — VALUE-based, so the + * External and Hermes-local cards (both `mode: external`) are never both shown + * selected. Hermes wins only when the draft carries its baked API URL + bank; + * generic External is "external mode that is NOT the Hermes preset". */ + const matchesPreset = (d, preset) => { + const mode = asText(d && d.mode, "external"); + const isHermes = mode === "external" && asText(d && d.externalUrl, "") === EX_API_URL && asText(d && d.bank, "") === "hermes"; + if (preset === "hermes") return isHermes; + if (preset === "external") return mode === "external" && !isHermes; + return mode === preset; + }; + + // ── Badge derivation — the 8-state model (UX doc §2), status-driven with a + // config-snapshot fallback. Splits managed states using the additive + // `status.runtimeStatus` when the host supplies it (legacy hosts collapse + // managed-up to Running on `healthy`, managed-down to Stopped). ── + function deriveBadge(entry) { + const s = entry.status; + const configured = s ? !!s.configured : !!entry.configured; + if (!configured) return { state: "dormant", label: "Not configured", hint: "No memory backend configured yet." }; + const mode = (s && s.mode) || (entry.config && entry.config.mode) || "external"; + if (!isManaged(mode)) { + if (s && s.healthy) return { state: "connected", label: "Connected", hint: "Connected to your Hindsight." }; + return { state: "unreachable", label: "Unreachable", hint: "Can't reach Hindsight at the configured URL." }; + } + // Managed modes. + const rs = s && s.runtimeStatus; + if (rs === "running" || (s && s.healthy)) return { state: "running", label: "Running", hint: "Managed runtime is running." }; + if (rs === "unhealthy") return { state: "unhealthy", label: "Unhealthy", hint: "Managed runtime is up but not healthy." }; + if (rs === "starting" || entry.runtimePhase === "starting") return { state: "starting", label: "Starting…", hint: "Managed runtime is starting…" }; + return { state: "stopped", label: "Stopped", hint: "Managed runtime is stopped." }; + } + + /** Required-inputs check for the managed Start gate. Reads ONLY the PERSISTED, + * redacted config (`*Set` flags + persisted `mode`) — NEVER the unsaved draft. + * Start launches Docker from the server's STORED config, so a freshly-typed but + * unsaved LLM key / Postgres URL (or an unsaved mode switch) must NOT satisfy the + * gate; the user has to Save first. The dirty-draft guard lives in + * `startDisabled`, which additionally blocks Start whenever there are edits. */ + const requiredInputsPresent = (entry) => { + const c = entry.config || {}; + const mode = c.mode; + const has = (field) => !!c[`${field}Set`]; + if (mode === "managed") return has("llmApiKey"); + if (mode === "managed-external-postgres") return has("llmApiKey") && has("externalDatabaseUrl"); + return false; + }; + + // ── Rendering ────────────────────────────────────────────────────────────── + const renderField = (label, testid, value, oninput, opts = {}) => html` + `; + + const renderSecret = (label, testid, field, entry, host, key, opts = {}) => { + const d = entry.draft || {}; + const setFlag = entry.config && entry.config[`${field}Set`]; + const placeholder = !entry.secretTouched[field] && setFlag ? "•••• set" : (opts.placeholder || ""); + return html` + `; + }; + + const renderToggle = (label, testid, checked, onchange) => html` + `; + + const renderApiIdentity = (entry) => { + const s = entry.status || {}; + const mode = asText(s.mode || (entry.config && entry.config.mode), "external"); + if (isManaged(mode)) return "managed runtime (loopback)"; + const url = asText(s.externalUrl || (entry.config && entry.config.externalUrl), ""); + return url || "—"; + }; + + const renderStatusCard = (entry, host, key) => { + const badge = deriveBadge(entry); + const s = entry.status || {}; + const mode = asText(s.mode || (entry.config && entry.config.mode), "external"); + const queueDepth = Number(s.queueDepth || 0); + const uiUrl = asText(s.uiUrl || (entry.config && entry.config.uiUrl), ""); + const timeoutMs = asText(s.timeoutMs != null ? s.timeoutMs : (entry.config && entry.config.timeoutMs), ""); + const recallBudget = asText(s.recallBudget != null ? s.recallBudget : (entry.config && entry.config.recallBudget), ""); + const lastError = s.lastError; + const lastErrMsg = lastError && typeof lastError === "object" ? asText(lastError.message) : asText(lastError, ""); + return html` +
    +
    +

    Runtime status

    +
    + ${badge.label} + +
    +
    + ${badge.hint ? html`

    ${badge.hint}

    ` : nothing} + ${entry.statusState === "error" + ? html`

    ${asText(entry.statusError, "Status unavailable")}

    ` + : html` +
    +
    Mode
    ${mode}
    +
    API URL
    ${renderApiIdentity(entry)}
    + ${uiUrl + ? html`` + : nothing} +
    Bank
    ${asText(s.bank || (entry.config && entry.config.bank), "bobbit")}
    +
    Namespace
    ${asText(s.namespace || (entry.config && entry.config.namespace), "default")}
    +
    Recall scope
    ${(() => { const sc = asText(s.recallScope || (entry.config && entry.config.recallScope), "project"); return sc === "all" ? "all (every project)" : "project (this project + shared/global)"; })()}
    +
    Auto recall / retain
    ${s.autoRecall === false ? "off" : "on"} / ${s.autoRetain === false ? "off" : "on"}
    + ${timeoutMs ? html`
    Timeout
    ${timeoutMs} ms
    ` : nothing} + ${recallBudget ? html`
    Recall budget
    ${recallBudget} tokens
    ` : nothing} +
    +
    + ${queueDepth} queued ${queueDepth === 1 ? "retain" : "retains"} + ${isManaged(mode) + ? html`` + : nothing} +
    + ${isManaged(mode) && entry.logsOpen ? renderLogsView(entry, host, key) : nothing} + ${lastErrMsg ? html`

    Last error: ${lastErrMsg}

    ` : nothing} + `} +
    `; + }; + + const renderLogsView = (entry, host, key) => html` +
    +
    + Runtime logs (tail ${LOGS_TAIL}) + +
    + ${entry.logsState === "error" + ? html`

    ${asText(entry.logsError, "Logs unavailable")}

    ` + : entry.logsState === "loading" && !entry.logs + ? html`

    Loading logs…

    ` + : html` + ${entry.logsError ? html`

    ${asText(entry.logsError)}

    ` : nothing} +
    ${entry.logs && entry.logs.length ? entry.logs : "No logs yet."}
    `} +
    `; + + // ── Guided setup walkthrough (UX doc §6, impl §4.4): deployment chooser + + // ownership matrix + recommended-defaults explainer + connection smoke test. ── + const DEPLOY_CARDS = [ + { preset: "hermes", title: "Hermes-local / embedded", mode: "external", bobbit: "Nothing — client only", you: "Hermes runs Hindsight for you", note: `Preset: API ${EX_API_URL}, bank hermes. No Docker.` }, + { preset: "external", title: "Connect existing Hindsight", mode: "external", bobbit: "Nothing — client only", you: "The whole Hindsight deployment", note: "No Docker — 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." }, + ]; + + const renderOwnership = () => html` +
    + 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. ${EX_API_URL}); Bobbit just connects.
    +
    +
    `; + + const renderDefaultsExplainer = () => html` +
    + 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.
    +
    +
    `; + + const renderSetupProgress = (entry) => { + const p = entry.setupProgress; + if (!p) return nothing; + const row = (label, state) => html` +
  • + + ${label} + ${state} +
  • `; + return html` +
      + ${row("Connection (health probe)", p.connection)} + ${row("Recall smoke test", p.recall)} +
    +

    Auto-retain happens on your next turn — Bobbit never writes a memory unsolicited.

    `; + }; + + const renderSetupCard = (entry, host, key) => { + const d = entry.draft || draftFromConfig(null); + return html` +
    +
    +

    Set up Hindsight

    + ${entry.configured + ? html`` + : nothing} +
    +

    Pick how Hindsight runs. Selecting a managed option only sets the mode — nothing starts until you press Start runtime.

    +
    + ${DEPLOY_CARDS.map((c) => { + const selected = matchesPreset(d, c.preset); + return html` + `; + })} +
    + ${renderOwnership()} + ${renderDefaultsExplainer()} +
    + +
    + ${renderSetupProgress(entry)} +
    `; + }; + + // ── Managed-runtime control card (UX doc §10, impl §4.5): consent disclosure + + // explicit Start/Stop + progress. NO auto-start: the ONLY start call lives in + // the Start button handler below. ── + const renderRuntimeProgress = (entry) => { + const s = entry.status || {}; + const rs = s.runtimeStatus; + const phase = entry.runtimePhase; + const healthy = !!s.healthy; + const stateFor = (done, active) => (done ? "ok" : active ? "running" : "pending"); + const started = phase === "starting" || rs === "starting" || rs === "running" || healthy; + const running = rs === "running" || (rs === undefined && healthy); + const errored = phase === "error"; + const row = (label, state) => html` +
  • + + ${label} + ${state} +
  • `; + if (phase === "idle" && !started && !errored) return nothing; + return html` +
      + ${row("Start runtime", errored ? "fail" : stateFor(started && (running || rs === "starting"), phase === "starting"))} + ${row("Health check", errored ? "fail" : stateFor(running, started && !running))} + ${row("Running", running ? "ok" : "pending")} +
    + ${entry.runtimeError ? html`

    ${asText(entry.runtimeError)}

    ` : 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

    + +
    + + +
    + ${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. + +
    ` + : nothing} + + + + ${mode === "external" + ? renderField("API / data-plane URL", "hindsight-external-url", d.externalUrl, (e) => setField(host, key, "externalUrl", e.currentTarget.value), { placeholder: EX_API_URL, hint: `API / data-plane URL Bobbit calls to recall & retain (e.g. ${EX_API_URL}). Activates external mode; empty keeps it dormant.`, validity: urlValidity }) + : nothing} + + ${renderField("Dashboard UI URL", "hindsight-ui-url", d.uiUrl, (e) => setField(host, key, "uiUrl", e.currentTarget.value), { placeholder: EX_UI_URL, hint: `Optional human dashboard opened by "Open Hindsight UI" — never called by Bobbit (e.g. ${EX_UI_URL}).`, validity: uiValidity })} + + ${isManaged(mode) + ? renderField("Managed data dir", "hindsight-data-dir", d.dataDir, (e) => setField(host, key, "dataDir", e.currentTarget.value), { placeholder: "~/.hindsight", hint: mode === "managed" ? "Host bind-mount path for managed Postgres data." : "" }) + : nothing} + + ${mode === "managed-external-postgres" + ? renderSecret("External Postgres URL", "hindsight-external-db-url", "externalDatabaseUrl", entry, host, key, { hint: "→ runtime HINDSIGHT_API_DATABASE_URL. Required to start." }) + : nothing} + + ${isManaged(mode) + ? renderSecret("LLM API key", "hindsight-llm-api-key", "llmApiKey", entry, host, key, { hint: "→ runtime HINDSIGHT_API_LLM_API_KEY. Required to start." }) + : nothing} + + ${renderSecret("API key", "hindsight-api-key", "apiKey", entry, host, key, { hint: "Optional bearer token for the Hindsight API." })} + +
    + ${renderField("Bank", "hindsight-bank", d.bank, (e) => setField(host, key, "bank", e.currentTarget.value), { placeholder: "bobbit" })} + ${renderField("Namespace", "hindsight-namespace", d.namespace, (e) => setField(host, key, "namespace", e.currentTarget.value), { placeholder: "default" })} +
    + + + +
    + ${renderToggle("Auto recall", "hindsight-auto-recall", d.autoRecall, (e) => setField(host, key, "autoRecall", e.currentTarget.checked))} + ${renderToggle("Auto retain", "hindsight-auto-retain", d.autoRetain, (e) => setField(host, key, "autoRetain", e.currentTarget.checked))} +
    + +
    + ${renderField("Recall budget (tokens)", "hindsight-recall-budget", d.recallBudget, (e) => setField(host, key, "recallBudget", e.currentTarget.value), { type: "number" })} + ${renderField("Timeout (ms)", "hindsight-timeout", d.timeoutMs, (e) => setField(host, key, "timeoutMs", e.currentTarget.value), { type: "number" })} +
    + + ${entry.saveErrors && entry.saveErrors.length + ? html`
      ${entry.saveErrors.map((err) => html`
    • ${asText(err)}
    • `)}
    ` + : nothing} +
    `; + }; + + const renderMemory = (mem, index) => { + void index; + const text = asText(mem && mem.text, ""); + const hasScore = mem && (typeof mem.score === "number"); + const id = mem && mem.id != null ? String(mem.id) : ""; + return html` +
  • +
    ${text}
    +
    + ${hasScore ? html`score ${Number(mem.score).toFixed(2)}` : nothing} + ${id ? html`${id}` : nothing} +
    +
  • `; + }; + + const renderSearchCard = (entry, host, key) => { + const onSubmit = (e) => { if (e) e.preventDefault(); runSearch(host, key); }; + const scope = entry.searchScope || (entry.config && entry.config.recallScope) || "project"; + return html` +
    +

    Search memory

    +
    + { const en = get(key); if (en) en.searchQuery = e.currentTarget.value; }} + /> + + +
    + ${renderSearchResults(entry)} +
    `; + }; + + const renderSearchResults = (entry) => { + if (entry.searchState === "searching") return html`

    Searching…

    `; + if (entry.searchState === "error") return html`

    ${asText(entry.searchError, "Search failed")}

    `; + if (entry.searchState === "empty") { + return entry.searchDormant + ? html`

    Configure Hindsight to search memory.

    ` + : html`

    No memories matched.

    `; + } + if (entry.searchState === "results") { + return html`
      ${entry.searchResults.map((mem, i) => renderMemory(mem, i))}
    `; + } + return nothing; + }; + + const STYLE = html``; + + return { + render(params, host) { + const key = (params && params.__sessionId) || "hindsight-default"; + + // Feature-detect Phase-2 callRoute; degrade gracefully on a Phase-1 host. + const canRoute = !!(host && host.capabilities && host.capabilities.callRoute && typeof host.callRoute === "function"); + if (!canRoute) { + return html`${STYLE}

    Hindsight memory is unavailable on this host.

    `; + } + + 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` +
    +

    Hindsight Memory

    +
    + ${headerUiUrl + ? html`Open Hindsight UI ↗` + : nothing} + ${entry.configured && !entry.setupOpen + ? html`` + : nothing} +
    +
    `; + })()} + ${renderStatusCard(entry, host, key)} + ${entry.configState === "error" + ? html`

    ${asText(entry.configError, "Config unavailable")}

    ` + : loadingConfig + ? html`

    Loading configuration…

    ` + : html` + ${setupVisible ? renderSetupCard(entry, host, key) : nothing} + ${renderConfigCard(entry, host, key)} + ${isManaged(draftMode) ? renderManagedCard(entry, host, key) : nothing}`} + ${renderSearchCard(entry, host, key)} +
    `; + }, + }; +} diff --git a/market-packs/hindsight/src/provider.ts b/market-packs/hindsight/src/provider.ts index ec367449c..c462df248 100644 --- a/market-packs/hindsight/src/provider.ts +++ b/market-packs/hindsight/src/provider.ts @@ -12,16 +12,34 @@ // unconfigured pack contributes no active provider at all. import { + applyBankMission, + applyDirectives, + buildAggregateContent, + clampRecallQuery, + clearError, clientConfig, + currentQueryTimestamp, enqueueRetain, isActive, + isQueryTooLongError, + loadPending, loadQueue, makeClient, + nextOverlap, + overlayProjectConfig, + recallTagFilter, recordError, resolveConfig, + savePending, saveQueue, + shouldFlushPending, truncate, type EffectiveConfig, + type EntityInput, + type HindsightClientLike, + type PendingBuffer, + type QueueEntry, + type RuntimeContext, type StoreLike, type Tags, } from "./shared.js"; @@ -45,6 +63,34 @@ interface ProviderCtx { summary?: string; span?: string; config?: unknown; + /** Optional lifecycle fields used by the v2 provider hooks. The host may pass + * richer objects; the provider reads them defensively. */ + headSha?: string; + prNumber?: string | number; + title?: string; + files?: unknown; + touchedFiles?: unknown; + changedFiles?: unknown; + components?: unknown; + decisions?: unknown; + achievements?: unknown; + branch?: string; + mergeTarget?: string; + completedAt?: string; + pullRequest?: { + url?: string; + number?: string | number; + title?: string; + state?: string; + headSha?: string; + }; + tasks?: unknown; + gates?: unknown; + /** Managed-runtime context injected by the host for ACTIVE managed Hindsight + * invocations (design § deployment modes). Absent in external mode and + * whenever the managed runtime is not running — the provider then stays dormant + * and NEVER starts Docker itself. */ + runtime?: RuntimeContext; host?: { store?: StoreLike }; } @@ -58,18 +104,91 @@ interface ContextBlock { } const TITLE = "Relevant memory"; +const MENTAL_MODEL_TITLE = "Project memory model"; const SUMMARY_CAP = 2000; +const DEFAULT_MENTAL_MODEL_REFRESH_EVERY_MS = 6 * 60 * 60 * 1000; +const DEFAULT_MENTAL_MODEL_MAX_TOKENS = 1000; +const DEFAULT_QUEUE_DRAIN_MAX = 10; +// Hook-path REST calls must finish below the provider manifest budget (4500ms) +// so a user-configured 15000ms route/tool timeout cannot stall an agent turn. +const HOOK_CLIENT_TIMEOUT_MS = 4000; +const MENTAL_MODEL_REFRESH_PREFIX = "mental-model-refresh:"; +const GOAL_COMPLETED_PREFIX = "goal-completed:"; +const inFlightGoalCompleted = new Set(); + +function hookClientConfig(cfg: EffectiveConfig, runtime?: RuntimeContext) { + const resolved = clientConfig(cfg, runtime); + const requested = typeof resolved.timeoutMs === "number" && Number.isFinite(resolved.timeoutMs) && resolved.timeoutMs > 0 + ? resolved.timeoutMs + : HOOK_CLIENT_TIMEOUT_MS; + return { ...resolved, timeoutMs: Math.min(requested, HOOK_CLIENT_TIMEOUT_MS) }; +} + +type MentalModelState = "injected" | "empty" | "skipped" | "failed"; + +interface MentalModelResult { + state: MentalModelState; + block?: ContextBlock; +} + +interface RetainExtras { + documentId?: string; + updateMode?: "replace" | "append"; + entities?: EntityInput[]; + observationScopes?: string[][]; + timestamp?: string; + metadata?: Record; +} function getStore(ctx: ProviderCtx): StoreLike | null { return ctx?.host?.store ?? null; } +function projectIdOf(ctx: ProviderCtx): string | undefined { + return ctx.projectId !== undefined ? String(ctx.projectId) : undefined; +} + +function sessionIdOf(ctx: ProviderCtx): string | undefined { + const s = ctx.sessionId !== undefined ? String(ctx.sessionId).trim() : ""; + return s.length > 0 ? s : undefined; +} + +/** Resolve the effective config for a hook: the host-merged `ctx.config` + * (server/global base) with the per-project overlay (safe memory-quality keys) + * layered on top. Activation/dormancy is gated on the BASE elsewhere; the overlay + * never changes mode/externalUrl. */ +async function effectiveConfig(ctx: ProviderCtx, base: EffectiveConfig): Promise { + const cfg = await overlayProjectConfig(base, getStore(ctx), projectIdOf(ctx)); + // v2 provider keys may not exist in older shared.ts while parallel API-surface + // work is in flight. Preserve raw hook config values structurally so provider + // mechanics can be tested and later shared helpers can type them. + const raw = rawConfig(ctx); + const extras: Record = {}; + for (const key of [ + "mentalModelEnabled", + "mentalModelRefreshEveryMs", + "mentalModelMaxTokens", + "recallQueryTimestampEnabled", + "directivesEnabled", + "directiveApplyMode", + "directiveSetVersion", + "directives", + "retainQueueHealthGate", + "retainQueueLlmHealthGate", + "retainQueueDrainMaxPerHook", + "retainQueueShutdownMax", + ] as const) { + if (key in raw) extras[key] = raw[key]; + } + return { ...cfg, ...extras } as EffectiveConfig; +} + function textOf(v: unknown): string | undefined { return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined; } /** Auto-tag taxonomy (the agent never hand-tags). Undefined values are omitted. */ -function autoTags(ctx: ProviderCtx, kind: "turn" | "compaction"): Tags { +function autoTags(ctx: ProviderCtx, kind: "turn" | "compaction" | "outcome"): Tags { const tags: Tags = { kind }; if (ctx.projectId) tags.project = String(ctx.projectId); if (ctx.goalId) tags.goal = String(ctx.goalId); @@ -78,6 +197,105 @@ function autoTags(ctx: ProviderCtx, kind: "turn" | "compaction"): Tags { return tags; } +function rawConfig(ctx: ProviderCtx): Record { + return ctx.config && typeof ctx.config === "object" && !Array.isArray(ctx.config) ? (ctx.config as Record) : {}; +} + +function cfgValue(ctx: ProviderCtx, cfg: EffectiveConfig, key: string): unknown { + const effective = cfg as unknown as Record; + return effective[key] ?? rawConfig(ctx)[key]; +} + +function cfgBool(ctx: ProviderCtx, cfg: EffectiveConfig, key: string, fallback: boolean): boolean { + const v = cfgValue(ctx, cfg, key); + return typeof v === "boolean" ? v : fallback; +} + +function cfgNumber(ctx: ProviderCtx, cfg: EffectiveConfig, key: string, fallback: number): number { + const v = cfgValue(ctx, cfg, key); + return typeof v === "number" && Number.isFinite(v) ? v : fallback; +} + +function normalizeList(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap((v) => normalizeList(v)); + if (typeof value === "string" && value.trim()) return [value.trim()]; + if (value && typeof value === "object") { + const out: string[] = []; + for (const v of Object.values(value as Record)) out.push(...normalizeList(v)); + return out; + } + return []; +} + +function unique(values: string[]): string[] { + return [...new Set(values.map((v) => v.trim()).filter(Boolean))]; +} + +function uniqueEntityInputs(items: EntityInput[]): EntityInput[] { + const seen = new Set(); + const out: EntityInput[] = []; + for (const item of items) { + const text = typeof item.text === "string" ? item.text.trim() : ""; + if (!text) continue; + const type = typeof item.type === "string" && item.type.trim().length > 0 ? item.type.trim() : undefined; + const key = `${type ?? ""}\0${text}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(type ? { text, type } : { text }); + } + return out; +} + +function tagArray(tags: Tags | undefined): string[] { + return Object.entries(tags ?? {}).map(([key, value]) => `${key}:${value}`); +} + +function projectObservationScopes(ctx: ProviderCtx): string[][] | undefined { + const projectId = projectIdOf(ctx)?.trim(); + return projectId ? [[`project:${projectId}`]] : undefined; +} + +function derivedEntities(ctx: ProviderCtx): EntityInput[] | undefined { + const entities = uniqueEntityInputs([ + ...unique([ + ...normalizeList(ctx.files), + ...normalizeList(ctx.touchedFiles), + ...normalizeList(ctx.changedFiles), + ]).map((text) => ({ text, type: "file" })), + ...unique(normalizeList(ctx.components)).map((text) => ({ text, type: "component" })), + ]); + return entities.length > 0 ? entities : undefined; +} + +function retainOpts(ctx: ProviderCtx, tags: Tags, sync: boolean, extras: RetainExtras = {}): Record { + return { + tags, + sync, + ...(extras.documentId ? { documentId: extras.documentId } : {}), + ...(extras.updateMode ? { updateMode: extras.updateMode } : {}), + ...(extras.entities && extras.entities.length > 0 ? { entities: extras.entities } : {}), + ...(extras.observationScopes && extras.observationScopes.length > 0 ? { observationScopes: extras.observationScopes } : {}), + ...(extras.timestamp ? { timestamp: extras.timestamp } : {}), + ...(extras.metadata ? { metadata: extras.metadata } : {}), + }; +} + +function queueEntry(ctx: ProviderCtx, cfg: EffectiveConfig, content: string, tags: Tags, extras: RetainExtras = {}): QueueEntry { + return { + content, + tags, + ts: Date.now(), + bank: cfg.bank, + namespace: cfg.namespace, + ...(extras.documentId ? { documentId: extras.documentId } : {}), + ...(extras.updateMode ? { updateMode: extras.updateMode } : {}), + ...(extras.entities && extras.entities.length > 0 ? { entities: extras.entities } : {}), + ...(extras.observationScopes && extras.observationScopes.length > 0 ? { observationScopes: extras.observationScopes } : {}), + ...(extras.timestamp ? { timestamp: extras.timestamp } : {}), + ...(extras.metadata ? { metadata: extras.metadata } : {}), + } as QueueEntry; +} + function buildTurnSummary(ctx: ProviderCtx): string { const parts: string[] = []; const user = textOf(ctx.prompt) ?? textOf(ctx.userText); @@ -93,19 +311,87 @@ function buildCompactSummary(ctx: ProviderCtx): string { return text ? text.slice(0, SUMMARY_CAP) : ""; } +function objectList(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((v): v is Record => !!v && typeof v === "object" && !Array.isArray(v)) : []; +} + +function stringField(obj: Record, key: string): string | undefined { + const value = obj[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function outcomePrNumber(ctx: ProviderCtx): string | undefined { + const fromCtx = ctx.prNumber !== undefined ? String(ctx.prNumber).trim() : ""; + if (fromCtx) return fromCtx; + const fromPr = ctx.pullRequest?.number !== undefined ? String(ctx.pullRequest.number).trim() : ""; + return fromPr || undefined; +} + +function outcomeLines(ctx: ProviderCtx, goalId: string, headSha: string, entities: EntityInput[] | undefined): string { + const prNumber = outcomePrNumber(ctx); + const title = textOf(ctx.title) ?? textOf(ctx.pullRequest?.title); + const lines: string[] = [`Goal completed: ${goalId}`]; + if (title) lines.push(`Title: ${title}`); + if (ctx.branch) lines.push(`Branch: ${ctx.branch}`); + if (ctx.mergeTarget) lines.push(`Merge target: ${ctx.mergeTarget}`); + if (headSha !== "unknown") lines.push(`Head SHA: ${headSha}`); + if (ctx.completedAt) lines.push(`Completed at: ${ctx.completedAt}`); + if (ctx.pullRequest || prNumber) { + const parts = [prNumber ? `#${prNumber}` : undefined, ctx.pullRequest?.state, ctx.pullRequest?.url].filter(Boolean); + lines.push(`Pull request: ${parts.join(" ") || "known"}`); + } + for (const value of normalizeList(ctx.achievements)) lines.push(`Achievement: ${value}`); + for (const value of normalizeList(ctx.decisions)) lines.push(`Decision: ${value}`); + const tasks = objectList(ctx.tasks); + if (tasks.length > 0) { + lines.push("Tasks:"); + for (const task of tasks.slice(0, 20)) { + const title = stringField(task, "title") ?? stringField(task, "id") ?? "task"; + const state = stringField(task, "state") ?? "unknown"; + const type = stringField(task, "type"); + const result = stringField(task, "resultSummary"); + lines.push(`- [${state}] ${title}${type ? ` (${type})` : ""}${result ? ` — ${truncate(result, 180)}` : ""}`); + } + } + const gates = objectList(ctx.gates); + if (gates.length > 0) { + lines.push("Gates:"); + for (const gate of gates.slice(0, 20)) { + const name = stringField(gate, "name") ?? stringField(gate, "gateId") ?? "gate"; + const status = stringField(gate, "status") ?? "unknown"; + const signalCount = typeof gate.signalCount === "number" ? `, signals=${gate.signalCount}` : ""; + const commit = stringField(gate, "latestCommitSha"); + lines.push(`- ${name}: ${status}${signalCount}${commit ? `, commit=${commit}` : ""}`); + } + } + if (entities?.length) { + lines.push("Files/components:"); + for (const entity of entities.slice(0, 50)) lines.push(`- ${entity.type ? `${entity.type}: ` : ""}${entity.text}`); + } + return lines.join("\n"); +} + async function doRecall(ctx: ProviderCtx, cfg: EffectiveConfig, query: string | undefined): Promise { if (!cfg.autoRecall) return []; const q = (query ?? "").trim(); if (!q) return []; - const tags: Tags | undefined = cfg.recallScope === "project" && ctx.projectId ? { project: String(ctx.projectId) } : undefined; + // Project scope maps to a project-tagged + (with tagsMatch `any`) untagged/global + // filter on the shared bank; `any_strict` excludes global; `all` scope sends none. + const filter = recallTagFilter(cfg.recallScope, projectIdOf(ctx), cfg.tagsMatch); const store = getStore(ctx); + // Clamp the query to avoid the data plane's 500-token "Query too long" 400. + const clampedQuery = clampRecallQuery(q, cfg.recallMaxInputChars); try { - const client = await makeClient(clientConfig(cfg)); - const res = await client.recall(cfg.bank, q, { + const client = await makeClient(hookClientConfig(cfg, ctx.runtime)); + const queryTimestamp = currentQueryTimestamp(cfgBool(ctx, cfg, "recallQueryTimestampEnabled", true)); + const res = await client.recall(cfg.bank, clampedQuery, { maxTokens: cfg.recallBudget, - ...(tags ? { tags, tagsMatch: "any" as const } : {}), - }); + types: cfg.recallTypes, + ...(queryTimestamp ? { queryTimestamp } : {}), + ...(filter ? { tags: filter.tags, tagsMatch: filter.tagsMatch } : {}), + } as never); + if (store) await clearError(store); const memories = res?.memories ?? []; if (memories.length === 0) return []; return [ @@ -119,42 +405,203 @@ async function doRecall(ctx: ProviderCtx, cfg: EffectiveConfig, query: string | }, ]; } catch (e) { + // The data plane's 500-token "Query too long" 400 is a SOFT skip: recall + // returns empty for this turn and we DO NOT record a sticky lastError (and + // clear any prior one), so the marketplace/panel banner can never reappear + // from this cause. The token-safe clamp should prevent it ever firing; this + // is defence in depth. Genuine errors still surface as before. + if (isQueryTooLongError(e)) { + if (store) await clearError(store); + return []; + } if (store) await recordError(store, e); return []; } } -/** Retry the queue HEAD (one entry) before the turn's own retain. */ -async function drainQueueHead(store: StoreLike, cfg: EffectiveConfig): Promise { - const q = await loadQueue(store); - if (q.length === 0) return; - const head = q[0]; +function mentalModelId(projectId: string): string { + return `bobbit-${projectId.replace(/[^a-zA-Z0-9_.:-]+/g, "-").replace(/^-+|-+$/g, "")}`; +} + +function mentalModelContent(model: unknown): string | undefined { + if (!model || typeof model !== "object") return undefined; + const m = model as Record; + if (typeof m.content === "string" && m.content.trim()) return m.content.trim(); + const rr = m.reflect_response; + if (rr && typeof rr === "object" && typeof (rr as Record).text === "string") { + const text = String((rr as Record).text).trim(); + return text || undefined; + } + return undefined; +} + +async function maybeRefreshMentalModel(ctx: ProviderCtx, cfg: EffectiveConfig, client: unknown, id: string, model: unknown): Promise { + const refresh = (client as { refreshMentalModel?: (bank: string, id: string) => Promise }).refreshMentalModel; + if (!refresh) return; + const store = getStore(ctx); + const everyMs = Math.max(0, cfgNumber(ctx, cfg, "mentalModelRefreshEveryMs", DEFAULT_MENTAL_MODEL_REFRESH_EVERY_MS)); + if (everyMs <= 0) return; + const key = `${MENTAL_MODEL_REFRESH_PREFIX}${cfg.namespace}:${cfg.bank}:${id}`; + const now = Date.now(); + let due = model && typeof model === "object" && (model as Record).is_stale === true; + if (store) { + const last = await store.get(key); + due = typeof last !== "number" || now - last >= everyMs; + } + if (!due) return; try { - const client = await makeClient(clientConfig(cfg)); - await client.ensureBank(cfg.bank); - await client.retain(cfg.bank, head.content, { tags: head.tags, sync: false }); - q.shift(); - await saveQueue(store, q); + await refresh.call(client, cfg.bank, id); + if (store) await store.put(key, now); + } catch { + // Refresh is opportunistic; stale-but-present content is still valuable. + } +} + +async function doMentalModel(ctx: ProviderCtx, cfg: EffectiveConfig): Promise { + if (!cfg.autoRecall || !cfgBool(ctx, cfg, "mentalModelEnabled", true)) return { state: "skipped" }; + const projectId = projectIdOf(ctx)?.trim(); + if (!projectId) return { state: "skipped" }; + const id = mentalModelId(projectId); + const filter = recallTagFilter("project", projectId, "all_strict"); + const tags = [`project:${projectId}`, "bobbit", "kind:mental-model"]; + try { + const client = (await makeClient(hookClientConfig(cfg, ctx.runtime))) as unknown as { + ensureMentalModel?: (bank: string, spec: Record) => Promise; + getMentalModel?: (bank: string, id: string) => Promise; + }; + if (!client.ensureMentalModel && !client.getMentalModel) return { state: "skipped" }; + const spec = { + id, + name: `Bobbit project memory: ${projectId}`, + sourceQuery: `Current durable project state, key decisions, architecture, conventions, open threads, and recent outcomes for project ${projectId}.`, + tags, + maxTokens: Math.max(1, cfgNumber(ctx, cfg, "mentalModelMaxTokens", DEFAULT_MENTAL_MODEL_MAX_TOKENS)), + trigger: { + fact_types: cfg.recallTypes, + exclude_mental_models: true, + include: null, + ...(filter ? { tags: tagArray(filter.tags), tags_match: filter.tagsMatch } : {}), + }, + }; + const ensured = client.ensureMentalModel ? await client.ensureMentalModel(cfg.bank, spec) : undefined; + const model = client.getMentalModel ? await client.getMentalModel(cfg.bank, id) : ensured; + const content = mentalModelContent(model); + if (!content) return { state: "empty" }; + await maybeRefreshMentalModel(ctx, cfg, client, id, model); + if (getStore(ctx)) await clearError(getStore(ctx)!); + return { + state: "injected", + block: { + id: "memory:mental-model", + title: MENTAL_MODEL_TITLE, + authority: "memory", + priority: 60, + reason: `Hindsight mental model for project ${projectId}`, + content, + }, + }; + } catch (e) { + const store = getStore(ctx); + if (store) await recordError(store, e); + return { state: "failed" }; + } +} + +/** Replay ONE queued entry into the bank/namespace it was ORIGINALLY routed to + * (captured at enqueue) — never the current hook's (possibly per-project- + * overridden) `cfg.bank`, so a failed retain can never cross banks/projects. + * Entries queued before the `bank`/`namespace` fields existed fall back to the + * current cfg. Throws on failure so callers can decide whether to requeue. */ +async function retainQueueEntry(store: StoreLike, cfg: EffectiveConfig, runtime: RuntimeContext | undefined, entry: QueueEntry): Promise { + const bank = entry.bank ?? cfg.bank; + const namespace = entry.namespace ?? cfg.namespace; + // Effective cfg pinned to the entry's target so ensureBank + mission + retain all + // agree on the ORIGINAL bank/namespace (deployment fields are server-global and + // unchanged by the per-project overlay, so reusing cfg's baseUrl/auth is correct). + const targetCfg: EffectiveConfig = { ...cfg, bank, namespace }; + const cc = hookClientConfig(cfg, runtime); + const client = await makeClient(cc.namespace === namespace ? cc : { ...cc, namespace }); + await client.ensureBank(bank); + await applyBankMission(store, client, targetCfg); + await applyDirectivesIfEnabled(store, client, targetCfg); + const extras = entry as QueueEntry & RetainExtras; + await client.retain(bank, entry.content, retainOpts({ projectId: entry.tags.project } as ProviderCtx, entry.tags, false, { + documentId: extras.documentId, + updateMode: extras.updateMode, + entities: extras.entities, + observationScopes: extras.observationScopes, + timestamp: extras.timestamp, + metadata: extras.metadata, + }) as never); +} + +async function applyDirectivesIfEnabled(store: StoreLike | null, client: unknown, cfg: EffectiveConfig): Promise { + await applyDirectives(store, client as HindsightClientLike, cfg); +} + +async function queueDrainHealthy(store: StoreLike, cfg: EffectiveConfig, runtime?: RuntimeContext): Promise { + if (cfg.retainQueueHealthGate === false) return true; + try { + const client = (await makeClient(hookClientConfig(cfg, runtime))) as unknown as { + health?: () => Promise<{ ok?: boolean }>; + llmHealth?: (bank: string) => Promise; + }; + const health = client.health ? await client.health() : { ok: true }; + if (health?.ok === false) return false; + if (cfg.retainQueueLlmHealthGate === true && client.llmHealth) { + const llm = await client.llmHealth(cfg.bank); + if (llm && typeof llm === "object") { + const llmObj = llm as Record; + if (llmObj.ok === false) return false; + const statuses = Object.values(llmObj); + if (statuses.some((v) => v && typeof v === "object" && (v as Record).ok === false)) return false; + } + } + return true; } catch (e) { - await recordError(store, e); // leave the head for a later attempt + await recordError(store, e); + return false; } } -/** Best-effort ONE-PASS drain of the whole queue (sessionShutdown). */ -async function drainQueueAll(store: StoreLike, cfg: EffectiveConfig): Promise { +/** Retry the queue HEAD (one entry) before the turn's own retain. */ +async function drainQueueHead(store: StoreLike, cfg: EffectiveConfig, runtime?: RuntimeContext): Promise { const q = await loadQueue(store); if (q.length === 0) return; - let client; - try { - client = await makeClient(clientConfig(cfg)); - } catch { - return; + if (!(await queueDrainHealthy(store, cfg, runtime))) return; + const limit = Math.max(0, Math.floor(cfg.retainQueueDrainMaxPerHook)); + if (limit <= 0) return; + for (let i = 0; i < limit && q.length > 0; i++) { + const head = q[0]; + try { + await retainQueueEntry(store, cfg, runtime, head); + q.shift(); + await saveQueue(store, q); + } catch (e) { + await recordError(store, e); // leave the head for a later attempt + return; + } } - const remaining = []; +} + +/** Best-effort ONE-PASS drain of the whole queue (sessionShutdown). Each entry is + * replayed into its OWN captured bank/namespace (a queue may mix banks across + * per-project overrides), so a failed entry never lands in another project's bank. */ +async function drainQueueAll(store: StoreLike, cfg: EffectiveConfig, runtime?: RuntimeContext): Promise { + const q = await loadQueue(store); + if (q.length === 0) return; + if (!(await queueDrainHealthy(store, cfg, runtime))) return; + const limit = Math.max(0, Math.floor(cfg.retainQueueShutdownMax || DEFAULT_QUEUE_DRAIN_MAX)); + const remaining: QueueEntry[] = []; + let drained = 0; for (const entry of q) { + if (drained >= limit) { + remaining.push(entry); + continue; + } try { - await client.ensureBank(cfg.bank); - await client.retain(cfg.bank, entry.content, { tags: entry.tags, sync: false }); + await retainQueueEntry(store, cfg, runtime, entry); + drained++; } catch { remaining.push(entry); } @@ -165,55 +612,177 @@ async function drainQueueAll(store: StoreLike, cfg: EffectiveConfig): Promise { const store = getStore(ctx); const tags = autoTags(ctx, kind); + const extras: RetainExtras = { observationScopes: projectObservationScopes(ctx), entities: derivedEntities(ctx), timestamp: new Date().toISOString() }; try { - const client = await makeClient(clientConfig(cfg)); + const client = await makeClient(hookClientConfig(cfg, ctx.runtime)); await client.ensureBank(cfg.bank); - await client.retain(cfg.bank, summary, { tags, sync }); + await applyBankMission(store, client, cfg); + await applyDirectivesIfEnabled(store, client, cfg); + await client.retain(cfg.bank, summary, retainOpts(ctx, tags, sync, extras) as never); + if (store) await clearError(store); } catch (e) { if (store) { - await enqueueRetain(store, { content: summary, tags, ts: Date.now() }); + await enqueueRetain(store, queueEntry(ctx, cfg, summary, tags, extras)); await recordError(store, e); } } } +/** Flush the durable per-session pending buffer as ONE aggregate retain (overlap + * context + every pending primary turn). On success the sticky error is cleared; + * on failure the aggregate is durably queued for retry (never dropped). EITHER + * way the buffer is advanced: the last `retainOverlapTurns` summaries are carried + * forward as bounded overlap and the primary turns are cleared so the count + * advances. No-op for an empty buffer. */ +async function flushPending(ctx: ProviderCtx, cfg: EffectiveConfig, store: StoreLike, sessionId: string, sync: boolean): Promise { + const buf = await loadPending(store, sessionId); + if (buf.turns.length === 0) return; + const content = buildAggregateContent(buf); + const tags = autoTags(ctx, "turn"); + const extras: RetainExtras = { observationScopes: projectObservationScopes(ctx), entities: derivedEntities(ctx), timestamp: new Date().toISOString() }; + try { + const client = await makeClient(hookClientConfig(cfg, ctx.runtime)); + await client.ensureBank(cfg.bank); + await applyBankMission(store, client, cfg); + await applyDirectivesIfEnabled(store, client, cfg); + await client.retain(cfg.bank, content, retainOpts(ctx, tags, sync, extras) as never); + await clearError(store); + } catch (e) { + await enqueueRetain(store, queueEntry(ctx, cfg, content, tags, extras)); + await recordError(store, e); + } + // Advance the buffer regardless of success (failures are durably queued): carry + // bounded overlap forward, clear the primary turns so the count advances. + const overlap = nextOverlap(buf.turns, cfg.retainOverlapTurns); + await savePending(store, sessionId, { turns: [], overlap }); +} + const provider = { async sessionSetup(ctx: ProviderCtx): Promise<{ blocks: ContextBlock[] }> { - const cfg = resolveConfig(ctx.config); - if (!isActive(cfg)) return { blocks: [] }; + const base = resolveConfig(ctx.config); + if (!isActive(base, ctx.runtime)) return { blocks: [] }; + const cfg = await effectiveConfig(ctx, base); + const mental = await doMentalModel(ctx, cfg); + if (mental.state === "injected" && mental.block) return { blocks: [mental.block] }; return { blocks: await doRecall(ctx, cfg, ctx.prompt) }; }, async beforePrompt(ctx: ProviderCtx): Promise<{ blocks: ContextBlock[] }> { - const cfg = resolveConfig(ctx.config); - if (!isActive(cfg)) return { blocks: [] }; + const base = resolveConfig(ctx.config); + if (!isActive(base, ctx.runtime)) return { blocks: [] }; + const cfg = await effectiveConfig(ctx, base); return { blocks: await doRecall(ctx, cfg, ctx.prompt) }; }, async afterTurn(ctx: ProviderCtx): Promise<{ blocks: ContextBlock[] }> { - const cfg = resolveConfig(ctx.config); - if (!isActive(cfg) || !cfg.autoRetain) return { blocks: [] }; + const base = resolveConfig(ctx.config); + if (!isActive(base, ctx.runtime) || !base.autoRetain) return { blocks: [] }; + const cfg = await effectiveConfig(ctx, base); const store = getStore(ctx); - if (store) await drainQueueHead(store, cfg); + if (store) await drainQueueHead(store, cfg, ctx.runtime); const summary = buildTurnSummary(ctx); - if (summary) await retainWithQueue(ctx, cfg, summary, "turn", false); + const sessionId = sessionIdOf(ctx); + // No durable buffer (no store or no session id) ⇒ capture-everything per-turn + // fallback (cannot batch without a place to hold pending turns). + if (!store || !sessionId) { + if (summary) await retainWithQueue(ctx, cfg, summary, "turn", false); + return { blocks: [] }; + } + // Batch (never sample): append this turn's compact summary to the durable + // pending buffer, then flush ONE aggregate when the batch is full or the + // oldest pending turn has aged past retainMaxDelayMs (hook-observed timeout). + let buf: PendingBuffer = await loadPending(store, sessionId); + if (summary) { + buf = { turns: [...buf.turns, { summary, ts: Date.now() }], overlap: buf.overlap }; + await savePending(store, sessionId, buf); + } + if (shouldFlushPending(buf, cfg.retainEveryNTurns, cfg.retainMaxDelayMs, Date.now())) { + await flushPending(ctx, cfg, store, sessionId, false); + } return { blocks: [] }; }, async beforeCompact(ctx: ProviderCtx): Promise<{ blocks: ContextBlock[] }> { - const cfg = resolveConfig(ctx.config); - if (!isActive(cfg) || !cfg.autoRetain) return { blocks: [] }; + const base = resolveConfig(ctx.config); + if (!isActive(base, ctx.runtime) || !base.autoRetain) return { blocks: [] }; + const cfg = await effectiveConfig(ctx, base); + const store = getStore(ctx); + const sessionId = sessionIdOf(ctx); + // Synchronously flush any pending buffered turns BEFORE the about-to-be-lost + // span so no batched turn is dropped when context is compacted. + if (store && sessionId) await flushPending(ctx, cfg, store, sessionId, true); const summary = buildCompactSummary(ctx); - // sync:true — land the about-to-be-lost span before context is dropped. + // sync:true, batch-EXEMPT — always land the about-to-be-lost span. if (summary) await retainWithQueue(ctx, cfg, summary, "compaction", true); return { blocks: [] }; }, async sessionShutdown(ctx: ProviderCtx): Promise<{ blocks: ContextBlock[] }> { - const cfg = resolveConfig(ctx.config); - if (!isActive(cfg)) return { blocks: [] }; + const base = resolveConfig(ctx.config); + if (!isActive(base, ctx.runtime)) return { blocks: [] }; + const cfg = await effectiveConfig(ctx, base); + const store = getStore(ctx); + const sessionId = sessionIdOf(ctx); + // Best-effort: flush any remaining buffered turns, then one-pass drain the + // durable retry queue. + if (store) { + if (base.autoRetain && sessionId) await flushPending(ctx, cfg, store, sessionId, false); + await drainQueueAll(store, cfg, ctx.runtime); + } + return { blocks: [] }; + }, + + async goalCompleted(ctx: ProviderCtx): Promise<{ blocks: ContextBlock[] }> { + const base = resolveConfig(ctx.config); + if (!isActive(base, ctx.runtime) || !base.autoRetain) return { blocks: [] }; + const cfg = await effectiveConfig(ctx, base); const store = getStore(ctx); - if (store) await drainQueueAll(store, cfg); + const goalId = ctx.goalId ? String(ctx.goalId) : "unknown"; + const headSha = (ctx.headSha ?? ctx.pullRequest?.headSha) ? String(ctx.headSha ?? ctx.pullRequest?.headSha) : "unknown"; + const marker = `${GOAL_COMPLETED_PREFIX}${goalId}:${headSha}`; + if (store && (await store.get(marker))) return { blocks: [] }; + if (inFlightGoalCompleted.has(marker)) return { blocks: [] }; + inFlightGoalCompleted.add(marker); + try { + if (store) await store.put(marker, { ts: Date.now(), state: "started" }); + const tags = autoTags(ctx, "outcome"); + tags.bobbit = "true"; + const prNumber = outcomePrNumber(ctx); + if (prNumber) tags.pr = prNumber; + const entities = derivedEntities(ctx); + const content = outcomeLines(ctx, goalId, headSha, entities); + const metadata: Record = { headSha }; + if (ctx.branch) metadata.branch = ctx.branch; + if (ctx.mergeTarget) metadata.mergeTarget = ctx.mergeTarget; + if (ctx.pullRequest?.url) metadata.pullRequestUrl = ctx.pullRequest.url; + const extras: RetainExtras = { + documentId: `outcome:${goalId}`, + updateMode: "replace", + entities, + observationScopes: projectObservationScopes(ctx), + timestamp: ctx.completedAt ?? new Date().toISOString(), + metadata, + }; + try { + const client = await makeClient(hookClientConfig(cfg, ctx.runtime)); + await client.ensureBank(cfg.bank); + await applyBankMission(store, client, cfg); + await applyDirectivesIfEnabled(store, client, cfg); + await client.retain(cfg.bank, content, retainOpts(ctx, tags, false, extras) as never); + if (store) { + await clearError(store); + await store.put(marker, { ts: Date.now(), state: "retained" }); + } + } catch (e) { + if (store) { + await enqueueRetain(store, queueEntry(ctx, cfg, content, tags, extras)); + await recordError(store, e); + await store.put(marker, { ts: Date.now(), state: "queued" }); + } + } + } finally { + inFlightGoalCompleted.delete(marker); + } return { blocks: [] }; }, }; diff --git a/market-packs/hindsight/src/routes.ts b/market-packs/hindsight/src/routes.ts index be9a3ed8a..6b3b0e8c4 100644 --- a/market-packs/hindsight/src/routes.ts +++ b/market-packs/hindsight/src/routes.ts @@ -12,18 +12,43 @@ // DORMANCY: when not configured (no external URL) every route returns a clean, // structured signal (`configured:false` / empty list) rather than erroring, so the // panel and tests get a deterministic dormant response with no network touched. +// +// REACHABILITY vs CONFIGURED (managed modes): `isConfigured` is the user-facing +// "a valid deployment is selected" gate (external needs a URL; a managed mode is +// configured the moment it is picked). It is NOT a license to dial a client. +// `clientConfig` only yields a usable base URL when there is a REACHABLE data +// plane — an external URL, or a host-injected running managed runtime +// (`ctx.runtime.baseUrl`). The route ctx carries `runtime` ONLY when the host +// injected one for this call; in a managed mode with no running runtime it is +// absent and `clientConfig(cfg)` would otherwise produce an EMPTY base URL. So +// every client-touching route gates on `isActive(cfg, ctx.runtime)` (the same +// reachability gate the provider uses) and reports a deterministic +// configured-but-not-healthy / dormant state instead of issuing an empty-base +// client call. External-mode behavior is unchanged (`isActive` == `isConfigured` +// there). See docs/design/hindsight-pack-external.md §6/§8 + the P3 runtime modes. import { + applyBankMission, + applyDirectives, + clampRecallQuery, + clearError, clientConfig, + isActive, isConfigured, + isQueryTooLongError, loadEffectiveConfig, + loadProjectOverride, loadQueue, makeClient, + projectConfigKey, + recallTagFilter, redactConfig, validateConfigOverrides, + validateProjectOverride, CONFIG_KEY, LAST_ERROR_KEY, type EffectiveConfig, + type RuntimeContext, type StoreLike, type Tags, } from "./shared.js"; @@ -37,6 +62,19 @@ interface RouteCtx { /** The calling session's project id, supplied by the host route ctx. Used to * scope a `project` recall to the REAL project; absent ⇒ no project filter. */ projectId?: string; + /** Effective goal id from trusted host session state (goalId, then teamGoalId). + * Used for automatic context tags on manual retains. */ + goalId?: string; + /** Calling role name from trusted host session state. Used as the `agent` tag on + * manual retains. */ + roleName?: string; + /** Managed-runtime context injected by the host for a route call against an + * ACTIVE managed Hindsight runtime (mirrors the provider's `ctx.runtime`). + * Carries the locally-running managed API base URL. Absent in external mode and + * whenever the managed runtime is not running — the route then reports a + * deterministic dormant/not-healthy state and NEVER dials an empty base URL + * (it never starts Docker). */ + runtime?: RuntimeContext; } interface RouteReq { method?: string; @@ -52,6 +90,92 @@ function strOf(v: unknown): string | undefined { return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined; } +function numOrStringOf(v: unknown): string | undefined { + if (typeof v === "number" && Number.isFinite(v)) return String(v); + return strOf(v); +} + +function isRecord(v: unknown): v is Record { + return isObj(v); +} + +const FACT_TYPES = new Set(["observation", "world", "experience"]); + +function factTypesOf(v: unknown): Array<"observation" | "world" | "experience"> | undefined { + if (!Array.isArray(v)) return undefined; + const out = v.filter((x): x is "observation" | "world" | "experience" => typeof x === "string" && FACT_TYPES.has(x)); + return out.length > 0 ? [...new Set(out)] : undefined; +} + +function queryTimestampOf(body: Record, cfg: EffectiveConfig): string | undefined { + if (body.queryTimestamp === false || body.queryTimestamp === null) return undefined; + const explicit = strOf(body.queryTimestamp); + if (explicit) return explicit; + if ((cfg as unknown as { recallQueryTimestampEnabled?: boolean }).recallQueryTimestampEnabled === false) return undefined; + return new Date().toISOString(); +} + +const BOBBIT_REFLECT_INSTRUCTION = [ + "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("\n"); + +function reflectPrompt(prompt: string, cfg: EffectiveConfig, body: Record): string { + // Bank-wide directives are disabled by default for shared banks. Only suppress + // the per-request Bobbit instruction when bank directives are explicitly enabled + // with a non-disabled apply mode and the route applies them before reflect. + if (body.bobbitInstruction === false) return prompt; + if (cfg.directivesEnabled && cfg.directiveApplyMode !== "disabled") return prompt; + return `${BOBBIT_REFLECT_INSTRUCTION}\n\nUser query:\n${prompt}`; +} + +interface EntityInput { text: string; type?: string } + +function entityListOf(v: unknown): EntityInput[] { + if (!Array.isArray(v)) return []; + const out: EntityInput[] = []; + for (const item of v) { + if (!isObj(item)) continue; + const text = strOf(item.text); + if (!text) continue; + const type = strOf(item.type); + out.push({ text, ...(type ? { type } : {}) }); + } + return out; +} + +function stringsOf(v: unknown): string[] { + return Array.isArray(v) ? v.map(strOf).filter((x): x is string => !!x) : []; +} + +function outcomeEntities(body: Record): EntityInput[] { + const entities = entityListOf(body.entities); + for (const file of stringsOf(body.files)) entities.push({ text: file, type: "file" }); + for (const component of stringsOf(body.components)) entities.push({ text: component, type: "component" }); + return entities; +} + +function outcomeTags(body: Record, projectId?: string): Tags { + const extra = isObj(body.tags) ? (body.tags as Tags) : {}; + const goalId = strOf(body.goalId); + const pr = numOrStringOf(body.pr); + return { + ...extra, + ...(projectId ? { project: projectId } : {}), + ...(goalId ? { goal: goalId } : {}), + ...(pr ? { pr } : {}), + bobbit: "true", + kind: "outcome", + }; +} + +function projectObservationScopes(projectId?: string): string[][] | undefined { + const pid = strOf(projectId); + return pid ? [[`project:${pid}`]] : undefined; +} + async function queueDepth(store: StoreLike): Promise { return (await loadQueue(store)).length; } @@ -64,9 +188,30 @@ async function lastError(store: StoreLike): Promise { } } -/** Auto-tags applied to a manual `retain` route call (mirrors the provider). */ -function manualTags(extra: Tags | undefined): Tags { - return { kind: "manual", ...(extra ?? {}) }; +/** Auto-tags applied to a manual `retain` route call (mirrors provider context). + * Canonical tags are derived from trusted route ctx and spread LAST so user tags + * stay additive but can NEVER spoof/override route-owned provenance/context. */ +function manualTags(ctx: RouteCtx, scope: "project" | "all", extra: Tags | undefined): Tags { + const projectId = strOf(ctx.projectId); + const canonical: Tags = { kind: "manual" }; + if (scope === "project" && projectId) canonical.project = projectId; + const goalId = strOf(ctx.goalId); + if (goalId) canonical.goal = goalId; + const sessionId = strOf(ctx.sessionId); + if (sessionId) canonical.session = sessionId; + const roleName = strOf(ctx.roleName); + if (roleName) canonical.agent = roleName; + return { ...(extra ?? {}), ...canonical }; +} + +/** Per-project config metadata for the GET/SET config surface. Returns the global + * (no-overlay) effective config and the raw stored project overlay (or null) when + * a project context is present; otherwise an empty object. */ +async function configMeta(store: StoreLike, projectId?: string): Promise> { + if (!projectId) return {}; + const globalCfg = await loadEffectiveConfig(store); + const projectOverride = await loadProjectOverride(store, projectId); + return { globalConfig: redactConfig(globalCfg), projectOverride: projectOverride ?? null }; } export const routes = { @@ -75,15 +220,31 @@ export const routes = { // the new effective config. config: async (ctx: RouteCtx, req: RouteReq) => { const store = ctx.host.store; + const projectId = strOf(ctx.projectId); const method = (req?.method ?? "GET").toUpperCase(); const hasBody = isObj(req?.body) && Object.keys(req!.body as object).length > 0; if (method === "GET" || !hasBody) { - const cfg = await loadEffectiveConfig(store); - return { ok: true, configured: isConfigured(cfg), config: redactConfig(cfg) }; + const cfg = await loadEffectiveConfig(store, projectId); + return { ok: true, configured: isConfigured(cfg), config: redactConfig(cfg), ...(await configMeta(store, projectId)) }; + } + + const body = req!.body as Record; + + // Per-project overlay write (safe memory-quality keys only). A full-replace: + // the validated overlay REPLACES the stored one (omitted/cleared keys are + // dropped ⇒ inherit global). Requires a project context. + if ("projectOverride" in body) { + if (!projectId) return { ok: false, error: "NO_PROJECT", errors: ["projectOverride requires a project context"] }; + const validation = validateProjectOverride(body.projectOverride); + if (!validation.ok) return { ok: false, error: "CONFIG_INVALID", errors: validation.errors ?? [] }; + await store.put(projectConfigKey(projectId), validation.value ?? {}); + const cfg = await loadEffectiveConfig(store, projectId); + return { ok: true, configured: isConfigured(cfg), config: redactConfig(cfg), ...(await configMeta(store, projectId)) }; } - const validation = validateConfigOverrides(req!.body); + // Global write (server-scope), merged over the stored global config. + const validation = validateConfigOverrides(body); if (!validation.ok) { return { ok: false, error: "CONFIG_INVALID", errors: validation.errors ?? [] }; } @@ -96,15 +257,18 @@ export const routes = { } const merged = { ...prev, ...(validation.value ?? {}) }; await store.put(CONFIG_KEY, merged); - const cfg = await loadEffectiveConfig(store); - return { ok: true, configured: isConfigured(cfg), config: redactConfig(cfg) }; + const cfg = await loadEffectiveConfig(store, projectId); + return { ok: true, configured: isConfigured(cfg), config: redactConfig(cfg), ...(await configMeta(store, projectId)) }; }, - // Health + queue + effective-config snapshot. `healthy` is a fresh probe when - // configured (short client timeout), else false. + // Health + queue + effective-config snapshot. `healthy` is a fresh probe when the + // data plane is reachable (external URL, or a host-injected running managed + // runtime via ctx.runtime; short client timeout), else false. status: async (ctx: RouteCtx) => { const store = ctx.host.store; - const cfg = await loadEffectiveConfig(store); + const projectId = strOf(ctx.projectId); + const cfg = await loadEffectiveConfig(store, projectId); + const projectOverride = projectId ? await loadProjectOverride(store, projectId) : undefined; const depth = await queueDepth(store); const err = await lastError(store); const base = { @@ -113,15 +277,32 @@ export const routes = { bank: cfg.bank, namespace: cfg.namespace, recallScope: cfg.recallScope, + tagsMatch: cfg.tagsMatch, autoRecall: cfg.autoRecall, autoRetain: cfg.autoRetain, + retainEveryNTurns: cfg.retainEveryNTurns, + retainMaxDelayMs: cfg.retainMaxDelayMs, + retainOverlapTurns: cfg.retainOverlapTurns, + // Per-project override indicator for the panel/marketplace status row. + projectOverrideActive: !!projectOverride, queueDepth: depth, + // Additive (UX surfacing — existing keys unchanged): the active configured + // values both the panel and marketplace render without a second round-trip. + // Both URLs are NON-secret; secrets are never echoed here. + externalUrl: cfg.externalUrl ?? "", // data-plane API URL + uiUrl: cfg.uiUrl ?? "", // human dashboard URL (display/open-only) + timeoutMs: cfg.timeoutMs, + recallBudget: cfg.recallBudget, ...(err ? { lastError: err } : {}), }; - if (!isConfigured(cfg)) return { ...base, healthy: false }; + // Probe health ONLY when there is a reachable data plane (an external URL, or a + // host-injected running managed runtime). A managed mode that is configured but + // has no running runtime reports `healthy:false` deterministically — the panel + // renders that as "Starting" — without ever dialing an empty base URL. + if (!isActive(cfg, ctx.runtime)) return { ...base, healthy: false }; let healthy = false; try { - const client = await makeClient(clientConfig(cfg)); + const client = await makeClient(clientConfig(cfg, ctx.runtime)); healthy = (await client.health()).ok === true; } catch { healthy = false; @@ -132,73 +313,188 @@ export const routes = { // { query, scope? } → resolve bank + tags and recall. Dormant ⇒ empty. recall: async (ctx: RouteCtx, req: RouteReq) => { const store = ctx.host.store; - const cfg = await loadEffectiveConfig(store); - if (!isConfigured(cfg)) return { configured: false, memories: [] }; + const cfg = await loadEffectiveConfig(store, strOf(ctx.projectId)); + // Recall needs a reachable data plane. Not active (unconfigured, or a managed + // mode with no running runtime) ⇒ a deterministic empty result that still + // reports whether a deployment is configured, with NO empty-base client call. + if (!isActive(cfg, ctx.runtime)) return { configured: isConfigured(cfg), memories: [] }; const body = isObj(req?.body) ? req!.body : {}; const query = strOf(body.query) ?? strOf(req?.query?.query); if (!query) return { configured: true, memories: [] }; const scope = body.scope === "project" || body.scope === "all" ? body.scope : cfg.recallScope; - // Scope a `project` recall to the REAL project id from the host route ctx. When - // the ctx carries no project (global/server-scope session), apply NO project - // filter rather than a fabricated placeholder tag. - const projectId = strOf(ctx.projectId); - const tags: Tags | undefined = scope === "project" && projectId ? { project: projectId } : undefined; + // Scope a `project` recall to the REAL project id from the host route ctx via + // the shared recallTagFilter: project-tagged PLUS (tagsMatch `any`) untagged/ + // global memories on the shared bank. `all`/no-project ⇒ no fabricated project + // tag. An optional `tags` map is a simple additive targeted filter (no DSL). + const extraTags = isObj(body.tags) ? (body.tags as Tags) : undefined; + const filter = recallTagFilter(scope, ctx.projectId, cfg.tagsMatch, extraTags); + // Clamp the query to avoid the data plane's 500-token "Query too long" 400. + const clampedQuery = clampRecallQuery(query, cfg.recallMaxInputChars); try { - const client = await makeClient(clientConfig(cfg)); - const res = await client.recall(cfg.bank, query, { + const client = await makeClient(clientConfig(cfg, ctx.runtime)); + const queryTimestamp = queryTimestampOf(body as Record, cfg); + const res = await client.recall(cfg.bank, clampedQuery, { maxTokens: cfg.recallBudget, - ...(tags ? { tags, tagsMatch: "any" as const } : {}), - }); + types: cfg.recallTypes, + ...(queryTimestamp ? { queryTimestamp } : {}), + ...(filter ? { tags: filter.tags, tagsMatch: filter.tagsMatch } : {}), + } as never); + await clearError(store); return { configured: true, memories: res?.memories ?? [] }; } catch (e) { + // The data plane's 500-token "Query too long" 400 is a SOFT skip: return a + // clean empty result with NO `error` field and clear any prior sticky error, + // so the panel/marketplace banner can never reappear from this cause (the + // token-safe clamp should already prevent it). Genuine errors still surface. + if (isQueryTooLongError(e)) { + await clearError(store); + return { configured: true, memories: [] }; + } return { configured: true, memories: [], error: String((e as { message?: unknown })?.message ?? e) }; } }, - // { content, tags?, sync? } → ensureBank + retain with merged auto-tags. + // { content, tags?, sync?, scope? } → ensureBank + retain with merged auto-tags. + // `scope` maps to a PROJECT TAG on the single shared bank (NOT a different bank): + // - scope "project" + a REAL project id in the route ctx ⇒ add `project:`. + // - scope "all" (or no project id) ⇒ no project tag fabricated from scope. + // User-supplied `tags` stay additive and never change the bank (cfg.bank). retain: async (ctx: RouteCtx, req: RouteReq) => { const store = ctx.host.store; - const cfg = await loadEffectiveConfig(store); - if (!isConfigured(cfg)) return { ok: false, configured: false }; + const projectId = strOf(ctx.projectId); + const cfg = await loadEffectiveConfig(store, projectId); + // A manual retain needs a reachable data plane; not active ⇒ report the + // configured surface without dialing an empty base URL. + if (!isActive(cfg, ctx.runtime)) return { ok: false, configured: isConfigured(cfg) }; const body = isObj(req?.body) ? req!.body : {}; const content = strOf(body.content); if (!content) return { ok: false, configured: true, error: "content is required" }; - const tags = manualTags(isObj(body.tags) ? (body.tags as Tags) : undefined); + const scope = body.scope === "project" || body.scope === "all" ? body.scope : cfg.recallScope; + const userTags = isObj(body.tags) ? (body.tags as Tags) : undefined; + const tags = manualTags(ctx, scope, userTags); const sync = body.sync === true; try { - const client = await makeClient(clientConfig(cfg)); + const client = await makeClient(clientConfig(cfg, ctx.runtime)); await client.ensureBank(cfg.bank); + await applyBankMission(store, client, cfg); await client.retain(cfg.bank, content, { tags, sync }); + await clearError(store); return { ok: true, configured: true }; } catch (e) { return { ok: false, configured: true, error: String((e as { message?: unknown })?.message ?? e) }; } }, - // { prompt } → reflect. Dormant ⇒ empty text. + // Dedicated outcome-digest retain route. It is intentionally stricter than the + // manual `retain` route: stable document id, replace semantics, async write, + // canonical outcome tags, optional file/component entities, and project + // observation scopes when the host provides a real project id. + retain_outcome: async (ctx: RouteCtx, req: RouteReq) => { + const store = ctx.host.store; + const projectId = strOf(ctx.projectId); + const cfg = await loadEffectiveConfig(store, projectId); + if (!isActive(cfg, ctx.runtime)) return { ok: false, configured: isConfigured(cfg) }; + const body = isObj(req?.body) ? req!.body : {}; + const content = strOf(body.content); + if (!content) return { ok: false, configured: true, error: "content is required" }; + const goalId = strOf(body.goalId); + const pr = numOrStringOf(body.pr); + const documentId = goalId ? `outcome:${goalId}` : pr ? `outcome:pr:${pr}` : undefined; + if (!documentId) return { ok: false, configured: true, error: "goalId or pr is required" }; + const timestamp = strOf(body.timestamp) ?? new Date().toISOString(); + const entities = outcomeEntities(body as Record); + try { + const client = await makeClient(clientConfig(cfg, ctx.runtime)); + await client.ensureBank(cfg.bank); + await applyBankMission(store, client, cfg); + await client.retain(cfg.bank, content, { + tags: outcomeTags(body as Record, projectId), + sync: false, + documentId, + updateMode: "replace", + timestamp, + ...(entities.length > 0 ? { entities } : {}), + ...(projectObservationScopes(projectId) ? { observationScopes: projectObservationScopes(projectId) } : {}), + } as never); + await clearError(store); + return { ok: true, configured: true, documentId }; + } catch (e) { + return { ok: false, configured: true, error: String((e as { message?: unknown })?.message ?? e), documentId }; + } + }, + + // { prompt, scope? } → reflect, with scope mapped to a tag filter on the single + // shared bank (NOT a different bank), mirroring `recall`: + // - scope "project" + a REAL project id in the route ctx ⇒ filter on `project:`. + // - scope "all" (or no project id) ⇒ no project filter (reflect over the bank). + // Dormant ⇒ empty text. reflect: async (ctx: RouteCtx, req: RouteReq) => { const store = ctx.host.store; - const cfg = await loadEffectiveConfig(store); - if (!isConfigured(cfg)) return { configured: false, text: "" }; + const cfg = await loadEffectiveConfig(store, strOf(ctx.projectId)); + if (!isActive(cfg, ctx.runtime)) return { configured: isConfigured(cfg), text: "" }; const body = isObj(req?.body) ? req!.body : {}; const prompt = strOf(body.prompt); if (!prompt) return { configured: true, text: "" }; + const scope = body.scope === "project" || body.scope === "all" ? body.scope : cfg.recallScope; + // Same shared tag-scoped filter as recall: project scope ⇒ project-tagged plus + // (tagsMatch `any`) untagged/global; `all`/no-project ⇒ reflect over the whole + // bank. An optional `tags` map is a simple additive targeted filter (no DSL). + const extraTags = isObj(body.tags) ? (body.tags as Tags) : undefined; + const filter = recallTagFilter(scope, ctx.projectId, cfg.tagsMatch, extraTags); + const responseSchema = isRecord(body.responseSchema) ? body.responseSchema : undefined; + const factTypes = factTypesOf(body.factTypes); try { - const client = await makeClient(clientConfig(cfg)); - const res = await client.reflect(cfg.bank, prompt); - return { configured: true, text: res?.text ?? "" }; + const client = await makeClient(clientConfig(cfg, ctx.runtime)); + await applyDirectives(store, client, cfg); + const reflectOptions = { + ...(filter ? { tags: filter.tags, tagsMatch: filter.tagsMatch } : {}), + ...(responseSchema ? { responseSchema } : {}), + ...(factTypes ? { factTypes } : {}), + ...(typeof body.excludeMentalModels === "boolean" ? { excludeMentalModels: body.excludeMentalModels } : {}), + ...(strOf(body.budget) ? { budget: strOf(body.budget) } : {}), + ...(typeof body.maxTokens === "number" && Number.isFinite(body.maxTokens) ? { maxTokens: body.maxTokens } : {}), + }; + const res = await client.reflect( + cfg.bank, + reflectPrompt(prompt, cfg, body as Record), + (Object.keys(reflectOptions).length > 0 ? reflectOptions : undefined) as never, + ); + return { configured: true, text: res?.text ?? "", ...("structuredOutput" in (res ?? {}) ? { structuredOutput: (res as { structuredOutput?: unknown }).structuredOutput } : {}) }; } catch (e) { return { configured: true, text: "", error: String((e as { message?: unknown })?.message ?? e) }; } }, + // Minimal reversible curation route. Agents can invalidate a stale/incorrect + // memory but cannot permanently delete it; revert remains manual/direct API. + invalidate: async (ctx: RouteCtx, req: RouteReq) => { + const store = ctx.host.store; + const cfg = await loadEffectiveConfig(store, strOf(ctx.projectId)); + if (!isActive(cfg, ctx.runtime)) return { ok: false, configured: isConfigured(cfg) }; + const body = isObj(req?.body) ? req!.body : {}; + const id = strOf(body.id); + const reason = strOf(body.reason); + if (!id) return { ok: false, configured: true, error: "id is required" }; + if (!reason) return { ok: false, configured: true, error: "reason is required" }; + try { + const client = await makeClient(clientConfig(cfg, ctx.runtime)); + const invalidateMemory = (client as unknown as { invalidateMemory?: (bank: string, id: string, reason: string) => Promise }).invalidateMemory; + if (typeof invalidateMemory !== "function") throw new Error("Hindsight client does not support memory invalidation"); + await invalidateMemory.call(client, cfg.bank, id, reason); + await clearError(store); + return { ok: true, configured: true, id }; + } catch (e) { + return { ok: false, configured: true, error: String((e as { message?: unknown })?.message ?? e), id }; + } + }, + // Diagnostic: list banks. Dormant ⇒ empty list. banks: async (ctx: RouteCtx) => { const store = ctx.host.store; - const cfg: EffectiveConfig = await loadEffectiveConfig(store); - if (!isConfigured(cfg)) return { configured: false, banks: [] }; + const cfg: EffectiveConfig = await loadEffectiveConfig(store, strOf(ctx.projectId)); + if (!isActive(cfg, ctx.runtime)) return { configured: isConfigured(cfg), banks: [] }; try { - const client = await makeClient(clientConfig(cfg)); + const client = await makeClient(clientConfig(cfg, ctx.runtime)); const res = await client.listBanks(); return { configured: true, banks: res?.banks ?? [] }; } catch (e) { diff --git a/market-packs/hindsight/src/shared.ts b/market-packs/hindsight/src/shared.ts index 8fdb74d84..d0f18fa16 100644 --- a/market-packs/hindsight/src/shared.ts +++ b/market-packs/hindsight/src/shared.ts @@ -17,23 +17,169 @@ export type Tags = Record; export type TagsMatch = "any" | "all" | "any_strict" | "all_strict"; +/** Tag-match mode for a PROJECT-scoped recall/reflect on the single shared bank. + * Per the Hindsight recall API, `"any"` means "OR, INCLUDES untagged": it returns + * memories tagged with this project AND untagged/global memories, while STILL + * excluding other projects' tagged memories. That is exactly the shared + * tag-scoped bank design — project scope = this project + global, never another + * project. (`"any_strict"` is "OR, EXCLUDES untagged"; we deliberately do NOT use + * it, which would drop global memories from a project recall.) */ +export const PROJECT_RECALL_TAGS_MATCH: TagsMatch = "any"; + +/** Hindsight fact types (recall `types` filter). Biasing recall toward consolidated + * `observation`s (plus `world`/`experience`) returns deduped knowledge over raw + * per-turn chatter. */ +export type RecallType = "observation" | "world" | "experience"; +export type RecallBudget = "low" | "mid" | "high"; +export type UpdateMode = "replace" | "append"; +export const RECALL_TYPES: readonly RecallType[] = ["observation", "world", "experience"]; + +export interface EntityInput { + text: string; + type?: string; +} + +export interface RecallInclude { + entities?: null | Record; + chunks?: null | Record; + source_facts?: null | Record; +} + +export interface RetainClientOptions { + tags?: Tags; + sync?: boolean; + documentId?: string; + updateMode?: UpdateMode; + entities?: EntityInput[]; + timestamp?: string; + observationScopes?: string | string[][]; + metadata?: Record; +} + +export interface ReflectClientOptions { + tags?: Tags; + tagsMatch?: TagsMatch; + responseSchema?: Record; + factTypes?: RecallType[]; + budget?: RecallBudget; + maxTokens?: number; + include?: RecallInclude; + excludeMentalModels?: boolean; + excludeMentalModelIds?: string[]; +} + +export interface DirectiveConfig { + name: string; + content: string; + priority?: number; + tags?: string[]; +} + +export interface OutcomeDigestInput { + projectId?: string; + goalId?: string; + pr?: string | number; + branch?: string; + mergeTarget?: string; + title?: string; + content?: string; + achievements?: string[]; + decisions?: string[]; + files?: string[]; + touchedFiles?: string[]; + components?: string[]; + tags?: Tags; + timestamp?: string; +} + +export interface OutcomeDigest { + content: string; + documentId: string; + tags: Tags; + entities?: EntityInput[]; + timestamp: string; + observationScopes?: string[][]; +} + +/** Resolve the recall/reflect tag filter for a deployment scope on the single + * shared bank (the single source of truth shared by the provider auto-recall and + * the `recall`/`reflect` routes): + * - `project` scope WITH a real project id, NO extra tags ⇒ `{ project: }` + * matched with `tagsMatch` (default `any` = project-tagged PLUS untagged/global; + * `any_strict` = project-only, EXCLUDING global — a rare opt-in). + * - `project` scope WITH extra tags ⇒ the extra tags NARROW the recall: require + * the project tag AND every extra tag, EXCLUDING untagged/global (`all_strict`). + * This never broadens past the current project — an extra tag (e.g. `goal:g`) + * can NOT pull in untagged/global memories nor OTHER projects that merely share + * that tag. The route-derived project tag is AUTHORITATIVE: an extra `project` + * tag can NEVER override it (it is dropped). Compound boolean queries are a + * direct-API escape hatch only (no `tag_groups` is ever exposed to tools). + * - `scope: all` (or no project id): NO fabricated project tag. An explicit + * `extraTags` filter (a simple targeted cross-project/goal query) is still + * applied additively, with `any` so global/untagged stays visible. */ +export function recallTagFilter( + scope: "project" | "all", + projectId: string | undefined, + tagsMatch: TagsMatch = PROJECT_RECALL_TAGS_MATCH, + extraTags?: Tags, +): { tags: Tags; tagsMatch: TagsMatch } | undefined { + const pid = typeof projectId === "string" && projectId.trim().length > 0 ? projectId.trim() : undefined; + const extra = extraTags && Object.keys(extraTags).length > 0 ? { ...extraTags } : undefined; + if (scope === "project" && pid) { + // The route-derived project tag is AUTHORITATIVE: drop any extra `project` so a + // caller-supplied tag can never override the current project. + const rest = { ...(extra ?? {}) }; + delete rest.project; + if (Object.keys(rest).length > 0) { + // Extra tags NARROW: require project AND every extra tag, EXCLUDE untagged/ + // global (all_strict). Replaces the old `any`-merge that broadened recall to + // untagged/global AND other-project memories sharing an extra tag. + return { tags: { project: pid, ...rest }, tagsMatch: "all_strict" }; + } + return { tags: { project: pid }, tagsMatch }; + } + if (extra) return { tags: extra, tagsMatch: "any" }; + return undefined; +} + export interface RecallMemory { text: string; score?: number; id?: string; } +/** Bank-config mission updates (snake_case to mirror the Hindsight REST API + * `PATCH …/banks/{bank}/config` body `{ updates: {...} }`). */ +export interface BankMissionUpdates { + retain_mission?: string; + observations_mission?: string; + reflect_mission?: string; +} + export interface HindsightClientLike { health(): Promise<{ ok: boolean }>; ensureBank(bank: string): Promise; recall( bank: string, query: string, - opts?: { maxTokens?: number; tags?: Tags; tagsMatch?: TagsMatch }, + opts?: { maxTokens?: number; budget?: RecallBudget; tags?: Tags; tagsMatch?: TagsMatch; types?: RecallType[]; include?: RecallInclude; queryTimestamp?: string; trace?: boolean }, ): Promise<{ memories: RecallMemory[] }>; - retain(bank: string, content: string, opts?: { tags?: Tags; sync?: boolean }): Promise; - reflect(bank: string, prompt: string): Promise<{ text: string }>; + retain(bank: string, content: string, opts?: RetainClientOptions): Promise; + reflect(bank: string, prompt: string, opts?: ReflectClientOptions): Promise<{ text: string; structuredOutput?: unknown }>; listBanks(): Promise<{ banks: string[] }>; + /** Idempotent bank-config mission update (PATCH …/config). Optional so unit-test + * fakes need not implement it; {@link applyBankMission} no-ops when absent. */ + updateBankConfig?(bank: string, updates: BankMissionUpdates): Promise; + getMentalModel?(bank: string, id: string): Promise<{ content?: string } | null>; + ensureMentalModel?(bank: string, opts: { id?: string; name: string; sourceQuery: string; tags?: string[]; maxTokens?: number; trigger?: Record }): Promise<{ model: { content?: string } | null; created: boolean; operationId?: string }>; + refreshMentalModel?(bank: string, id: string): Promise<{ operationId?: string }>; + listDirectives?(bank: string): Promise<{ items: Array<{ id: string; name?: string; content?: string; priority?: number; is_active?: boolean; tags?: string[] }> }>; + createDirective?(bank: string, directive: { name: string; content: string; priority?: number; isActive?: boolean; tags?: string[] }): Promise; + updateDirective?(bank: string, id: string, patch: { name?: string; content?: string; priority?: number; isActive?: boolean; tags?: string[] }): Promise; + llmHealth?(bank: string): Promise; + listOperations?(bank: string): Promise<{ items: unknown[] }>; + invalidateMemory?(bank: string, id: string, reason: string): Promise; + getMemoryHistory?(bank: string, id: string): Promise<{ history: unknown[] }>; } export interface ClientConfig { @@ -43,6 +189,38 @@ export interface ClientConfig { timeoutMs?: number; } +// ── Managed-runtime context (P3) ─────────────────────────────────────────────── +// Injected by the host (lifecycle hub) for ACTIVE managed Hindsight provider +// invocations only. `baseUrl` points at the locally-running managed Hindsight API +// (`http://127.0.0.1:`); `headers` mirrors the apiKey-derived auth the +// client also builds; `status` is the supervisor's runtime state. Absent in +// external mode and whenever the managed runtime is not running — the provider +// stays dormant in that case (it NEVER starts Docker itself). +export interface RuntimeContext { + baseUrl: string; + headers?: Record; + status?: string; +} + +/** The deployment modes that are backed by a Bobbit-managed Docker runtime + * (Hindsight API/web, with managed or external Postgres). External mode keeps + * the unchanged operator-supplied data-plane URL path. */ +export function isManagedMode(mode: string): boolean { + return mode === "managed" || mode === "managed-external-postgres"; +} + +/** Map a deployment `mode` onto the runtime descriptor's launch mode key + * (market-packs/hindsight/runtimes/hindsight.yaml::modes). `managed` brings up a + * managed Postgres (`managed-postgres`); `managed-external-postgres` omits the + * `db` service and injects HINDSIGHT_API_DATABASE_URL (`external-postgres`). + * External mode launches no runtime ⇒ undefined. This is the single source of + * truth for the config-mode → runtime-mode mapping the host's enable action uses. */ +export function runtimeModeFor(mode: string): "managed-postgres" | "external-postgres" | undefined { + if (mode === "managed") return "managed-postgres"; + if (mode === "managed-external-postgres") return "external-postgres"; + return undefined; +} + export type ClientFactory = (cfg: ClientConfig) => HindsightClientLike | Promise; let clientFactoryOverride: ClientFactory | null = null; @@ -64,26 +242,130 @@ export async function makeClient(cfg: ClientConfig): Promise ({ ...d, tags: d.tags ? [...d.tags] : undefined })), + retainQueueDrainMaxPerHook: 1, + retainQueueShutdownMax: 10, + retainQueueHealthGate: true, + retainQueueLlmHealthGate: false, + retainQueueBatchPauseMs: 0, + retainMission: DEFAULT_RETAIN_MISSION, + observationsMission: DEFAULT_OBSERVATIONS_MISSION, + reflectMission: DEFAULT_REFLECT_MISSION, + recallMaxInputChars: 3000, + timeoutMs: 4000, }; function isObj(v: unknown): v is Record { @@ -110,39 +392,193 @@ function asBool(v: unknown, d: boolean): boolean { function asNum(v: unknown, d: number): number { return typeof v === "number" && Number.isFinite(v) ? v : d; } +function isRecallType(v: unknown): v is RecallType { + return v === "observation" || v === "world" || v === "experience"; +} +/** Parse a recall `types` array; falls back to the default when absent/invalid. */ +function asRecallTypes(v: unknown, d: RecallType[]): RecallType[] { + if (!Array.isArray(v)) return [...d]; + const valid = v.filter(isRecallType); + return valid.length > 0 ? [...new Set(valid)] : [...d]; +} +function isDirectiveApplyMode(v: unknown): v is EffectiveConfig["directiveApplyMode"] { + return v === "disabled" || v === "bank-wide-explicit-opt-in" || v === "scoped-if-supported"; +} +function asDirectives(v: unknown, d: DirectiveConfig[]): DirectiveConfig[] { + if (!Array.isArray(v)) return d.map((x) => ({ ...x, tags: x.tags ? [...x.tags] : undefined })); + const out: DirectiveConfig[] = []; + for (const item of v) { + if (!isObj(item) || typeof item.name !== "string" || item.name.trim().length === 0 || typeof item.content !== "string") continue; + out.push({ + name: item.name.trim(), + content: item.content, + ...(typeof item.priority === "number" && Number.isFinite(item.priority) ? { priority: item.priority } : {}), + ...(Array.isArray(item.tags) ? { tags: item.tags.filter((t) => typeof t === "string") } : {}), + }); + } + return out.length > 0 ? out : d.map((x) => ({ ...x, tags: x.tags ? [...x.tags] : undefined })); +} export function resolveConfig(raw: unknown): EffectiveConfig { const externalUrl = asString(flat(raw, "externalUrl")); + const uiUrl = asString(flat(raw, "uiUrl")); const apiKey = asString(flat(raw, "apiKey")); - const recallScope = flat(raw, "recallScope") === "project" ? "project" : "all"; + const externalDatabaseUrl = asString(flat(raw, "externalDatabaseUrl")); + const llmApiKey = asString(flat(raw, "llmApiKey")); + // Default scope is now `project` (project + global via tags_match `any`); only an + // explicit `all` opts into whole-bank recall. + const recallScope = flat(raw, "recallScope") === "all" ? "all" : "project"; + const tagsMatch = flat(raw, "tagsMatch") === "any_strict" ? "any_strict" : "any"; + const retainEveryNTurns = Math.max(1, Math.floor(asNum(flat(raw, "retainEveryNTurns"), CONFIG_DEFAULTS.retainEveryNTurns))); + const retainMaxDelayMs = Math.max(0, Math.floor(asNum(flat(raw, "retainMaxDelayMs"), CONFIG_DEFAULTS.retainMaxDelayMs))); + const retainOverlapTurns = Math.max(0, Math.floor(asNum(flat(raw, "retainOverlapTurns"), CONFIG_DEFAULTS.retainOverlapTurns))); + const directiveApplyModeRaw = flat(raw, "directiveApplyMode"); + const retainQueueDrainMaxPerHook = Math.max(0, Math.floor(asNum(flat(raw, "retainQueueDrainMaxPerHook"), CONFIG_DEFAULTS.retainQueueDrainMaxPerHook))); + const retainQueueShutdownMax = Math.max(0, Math.floor(asNum(flat(raw, "retainQueueShutdownMax"), CONFIG_DEFAULTS.retainQueueShutdownMax))); + const retainQueueBatchPauseMs = Math.max(0, Math.floor(asNum(flat(raw, "retainQueueBatchPauseMs"), CONFIG_DEFAULTS.retainQueueBatchPauseMs))); return { mode: asString(flat(raw, "mode")) ?? CONFIG_DEFAULTS.mode, ...(externalUrl ? { externalUrl } : {}), + ...(uiUrl ? { uiUrl } : {}), ...(apiKey ? { apiKey } : {}), + ...(externalDatabaseUrl ? { externalDatabaseUrl } : {}), + ...(llmApiKey ? { llmApiKey } : {}), + dataDir: asString(flat(raw, "dataDir")) ?? CONFIG_DEFAULTS.dataDir, bank: asString(flat(raw, "bank")) ?? CONFIG_DEFAULTS.bank, namespace: asString(flat(raw, "namespace")) ?? CONFIG_DEFAULTS.namespace, recallScope, + tagsMatch, autoRecall: asBool(flat(raw, "autoRecall"), CONFIG_DEFAULTS.autoRecall), autoRetain: asBool(flat(raw, "autoRetain"), CONFIG_DEFAULTS.autoRetain), + retainEveryNTurns, + retainMaxDelayMs, + retainOverlapTurns, recallBudget: asNum(flat(raw, "recallBudget"), CONFIG_DEFAULTS.recallBudget), + recallTypes: asRecallTypes(flat(raw, "recallTypes"), CONFIG_DEFAULTS.recallTypes), + recallQueryTimestampEnabled: asBool(flat(raw, "recallQueryTimestampEnabled"), CONFIG_DEFAULTS.recallQueryTimestampEnabled), + mentalModelEnabled: asBool(flat(raw, "mentalModelEnabled"), CONFIG_DEFAULTS.mentalModelEnabled), + mentalModelMaxTokens: Math.max(1, Math.floor(asNum(flat(raw, "mentalModelMaxTokens"), CONFIG_DEFAULTS.mentalModelMaxTokens))), + mentalModelRefreshEveryMs: Math.max(0, Math.floor(asNum(flat(raw, "mentalModelRefreshEveryMs"), CONFIG_DEFAULTS.mentalModelRefreshEveryMs))), + mentalModelRecallMaxTokens: Math.max(1, Math.floor(asNum(flat(raw, "mentalModelRecallMaxTokens"), CONFIG_DEFAULTS.mentalModelRecallMaxTokens))), + directivesEnabled: asBool(flat(raw, "directivesEnabled"), CONFIG_DEFAULTS.directivesEnabled), + directiveApplyMode: isDirectiveApplyMode(directiveApplyModeRaw) ? directiveApplyModeRaw : CONFIG_DEFAULTS.directiveApplyMode, + directiveSetVersion: asString(flat(raw, "directiveSetVersion")) ?? CONFIG_DEFAULTS.directiveSetVersion, + directives: asDirectives(flat(raw, "directives"), CONFIG_DEFAULTS.directives), + retainQueueDrainMaxPerHook, + retainQueueShutdownMax, + retainQueueHealthGate: asBool(flat(raw, "retainQueueHealthGate"), CONFIG_DEFAULTS.retainQueueHealthGate), + retainQueueLlmHealthGate: asBool(flat(raw, "retainQueueLlmHealthGate"), CONFIG_DEFAULTS.retainQueueLlmHealthGate), + retainQueueBatchPauseMs, + retainMission: asString(flat(raw, "retainMission")) ?? CONFIG_DEFAULTS.retainMission, + observationsMission: asString(flat(raw, "observationsMission")) ?? CONFIG_DEFAULTS.observationsMission, + reflectMission: asString(flat(raw, "reflectMission")) ?? CONFIG_DEFAULTS.reflectMission, + recallMaxInputChars: asNum(flat(raw, "recallMaxInputChars"), CONFIG_DEFAULTS.recallMaxInputChars), timeoutMs: asNum(flat(raw, "timeoutMs"), CONFIG_DEFAULTS.timeoutMs), }; } -/** The dormancy gate (the central invariant): active ONLY in external mode with a - * non-empty URL. Inactive ⇒ every hook is a no-op and no client is constructed. */ -export function isActive(cfg: EffectiveConfig): boolean { - return cfg.mode === "external" && typeof cfg.externalUrl === "string" && cfg.externalUrl.trim().length > 0; +// ── Token-safe recall-query clamp ───────────────────────────────────────────── +// +// Hindsight caps the recall QUERY at 500 tokens and returns +// `HTTP 400 {"detail":"Query too long: tokens exceeds maximum of 500..."}`. +// The configured `recallMaxInputChars` (default 3000) is a CHAR limit, but ~3000 +// dense chars ≈ 1400+ tokens ⇒ still 400. The char clamp is the wrong unit, so we +// ALWAYS enforce a hard token-safe CHARACTER ceiling derived from the token cap — +// regardless of the configured char value (even when char-clamping is "disabled"). +// +// Ratio rationale: real recall queries are natural-language turn text, which runs +// ~4–6 chars/token (a live 9200-char query measured ~5.75 chars/token against the +// data plane). We deliberately use a CONSERVATIVE 3.5 chars/token — below realistic +// prose — and target ~450 tokens (headroom below the 500 hard cap). 450 tokens × +// 3.5 chars/token ≈ 1575 ⇒ a 1600-char ceiling sits comfortably under 500 tokens +// (1600 ÷ 3.5 ≈ 457 tokens) even for denser-than-estimated text. +/** Hindsight's hard recall-query token cap (HTTP 400 "Query too long" above it). */ +export const RECALL_QUERY_TOKEN_CAP = 500; +/** Conservative chars/token estimate (below realistic ~4–6 prose) used to convert + * the token cap into a character ceiling. */ +export const RECALL_QUERY_CHARS_PER_TOKEN = 3.5; +/** Hard token-safe CHARACTER ceiling for the recall query, ALWAYS enforced + * regardless of `recallMaxInputChars`. 1600 chars ÷ 3.5 ≈ 457 tokens — under the + * 500-token cap with headroom (see the block comment above for the derivation). */ +export const RECALL_QUERY_SAFE_CHAR_CEILING = 1600; + +/** Clamp a recall QUERY so it can NEVER trip the data plane's 500-token "Query too + * long" 400. Trims first, then slices to the SMALLER of the configured `maxChars` + * char clamp (when `maxChars > 0`) and the hard {@link RECALL_QUERY_SAFE_CHAR_CEILING} + * token-safe ceiling. The token-safe ceiling is enforced even when char-clamping is + * disabled (`maxChars <= 0` / non-finite) — the query stays under the token cap + * regardless of the configured value. Pure; never throws. */ +export function clampRecallQuery(query: string, maxChars: number): string { + const trimmed = (query ?? "").trim(); + const charClamp = Number.isFinite(maxChars) && maxChars > 0 ? maxChars : Number.POSITIVE_INFINITY; + const effective = Math.min(charClamp, RECALL_QUERY_SAFE_CHAR_CEILING); + return trimmed.length <= effective ? trimmed : trimmed.slice(0, effective); +} + +/** Classify a recall failure as the data plane's 500-token "Query too long" 400 — + * a SOFT skip, not a real error. Hindsight returns + * `HTTP 400 {"detail":"Query too long: tokens exceeds maximum of 500..."}`. + * Even with {@link clampRecallQuery} this is defence in depth: if it ever fires, + * recall returns empty for the turn and the provider/route does NOT record it as a + * sticky `lastError` (it clears any prior one), so the marketplace/panel banner can + * never reappear from this cause. Genuine failures (network/5xx/timeout, and other + * 4xx such as auth) are unaffected and still surface. + * + * Detected STRUCTURALLY (no static dependency on the client's `HindsightError`): + * `kind:"http"` + `status:400` + a message that names the query AND carries + * query-too-long/token-limit wording (the client surfaces the upstream `detail` + * body in the error message). */ +export function isQueryTooLongError(e: unknown): boolean { + if (!e || typeof e !== "object") return false; + const err = e as { kind?: unknown; status?: unknown; message?: unknown }; + if (err.kind !== "http" || err.status !== 400) return false; + const msg = typeof err.message === "string" ? err.message.toLowerCase() : ""; + if (!/\bquery\b/.test(msg)) return false; + if (/\btoo\s+(?:long|large|many\s+tokens?)\b/.test(msg)) return true; + return /\b(?:exceeds?|exceeded)\b.*\b(?:max(?:imum)?|limit|tokens?)\b/.test(msg) + || /\b(?:max(?:imum)?|limit|tokens?)\b.*\b(?:exceeds?|exceeded)\b/.test(msg); +} + +/** The dormancy gate (the central invariant): the provider runs a hook's work + * ONLY when active; inactive ⇒ every hook is a no-op and no client is constructed. + * + * - External mode: active ONLY with a non-empty `externalUrl` (unchanged). + * - Managed modes: active ONLY when the host injected a running managed runtime + * (`runtime.baseUrl` present and `status` not stopped/unhealthy/starting). The + * provider NEVER starts Docker — an absent/stopped/unhealthy runtime simply + * keeps it dormant, so recall yields no blocks and retains queue. */ +export function isActive(cfg: EffectiveConfig, runtime?: RuntimeContext): boolean { + if (cfg.mode === "external") { + return typeof cfg.externalUrl === "string" && cfg.externalUrl.trim().length > 0; + } + if (isManagedMode(cfg.mode)) { + if (!runtime || typeof runtime.baseUrl !== "string" || runtime.baseUrl.trim().length === 0) return false; + // Defensive: only a running runtime serves recall/retain. A known + // stopped/unhealthy/starting/docker-unavailable status keeps us dormant; an + // unspecified status is tolerated (treated as reachable). + return runtime.status === undefined || runtime.status === "running"; + } + return false; } -/** Same gate phrased for the routes' "configured" surface. */ +/** The routes' "configured" surface (no runtime context available). External mode + * needs a URL; a managed mode is "configured" once the user selects it — runtime + * health is a separate, runtime-context-gated concern surfaced via `isActive`. */ export function isConfigured(cfg: EffectiveConfig): boolean { - return isActive(cfg); + if (cfg.mode === "external") { + return typeof cfg.externalUrl === "string" && cfg.externalUrl.trim().length > 0; + } + return isManagedMode(cfg.mode); } -export function clientConfig(cfg: EffectiveConfig): ClientConfig { +/** Build the REST client config for the effective deployment mode. Managed modes + * ignore `externalUrl` and dial the host-injected managed runtime base URL; + * external mode keeps the operator-supplied URL. The apiKey (when set) drives the + * client's own `Authorization` header, mirroring `runtime.headers`. */ +export function clientConfig(cfg: EffectiveConfig, runtime?: RuntimeContext): ClientConfig { + const base = isManagedMode(cfg.mode) ? (runtime?.baseUrl ?? "") : (cfg.externalUrl ?? ""); return { - baseUrl: (cfg.externalUrl ?? "").replace(/\/+$/, ""), + baseUrl: base.replace(/\/+$/, ""), ...(cfg.apiKey ? { apiKey: cfg.apiKey } : {}), namespace: cfg.namespace, timeoutMs: cfg.timeoutMs, @@ -160,6 +596,19 @@ export interface QueueEntry { content: string; tags: Tags; ts: number; + /** Target bank captured at ENQUEUE time so a retry always replays into the bank + * the retain was originally routed to — never the next hook's (possibly + * per-project-overridden) `cfg.bank`. Optional for backward compat with entries + * queued before this field existed (drain falls back to the current cfg). */ + bank?: string; + /** Target namespace captured at ENQUEUE time (mirrors {@link bank}). */ + namespace?: string; + documentId?: string; + updateMode?: UpdateMode; + entities?: EntityInput[]; + timestamp?: string; + observationScopes?: string | string[][]; + metadata?: Record; } export const QUEUE_KEY = "retain-queue"; @@ -168,8 +617,22 @@ export const LAST_ERROR_KEY = "last-error"; // The host overlays this key over provider yaml defaults and evaluates // activation.requiresConfig against it before bridge injection. export const CONFIG_KEY = "provider-config:memory"; +/** Per-project config overlay key prefix (pack-managed, same pack store). The + * overlay holds memory-quality keys only and layers OVER the global CONFIG_KEY. */ +export const PROJECT_CONFIG_KEY_PREFIX = "provider-config:memory:project:"; +/** Last-applied bank-mission signature cache prefix (one per namespace:bank). */ +export const BANK_CONFIG_APPLIED_PREFIX = "bank-config-applied:"; +/** Durable per-session auto-retain PENDING BUFFER prefix (holds the compact turn + * summaries awaiting an aggregate flush — batching, never sampling). */ +export const RETAIN_PENDING_PREFIX = "retain-pending:"; +export const MENTAL_MODEL_REFRESH_PREFIX = "mental-model-refresh:"; +export const DIRECTIVES_APPLIED_PREFIX = "directives-applied:"; export const QUEUE_CAP = 100; +export function projectConfigKey(projectId: string): string { + return `${PROJECT_CONFIG_KEY_PREFIX}${projectId}`; +} + export async function loadQueue(store: StoreLike): Promise { try { const v = await store.get(QUEUE_KEY); @@ -203,6 +666,18 @@ export async function recordError(store: StoreLike, e: unknown): Promise { } } +/** Clear the sticky `lastError` after a SUCCESSFUL data-plane operation so a + * transient failure (e.g. a since-fixed "Query too long" 400) does not show + * stickily in the marketplace row / panel. Best-effort; never throws and is + * NEVER called on failure. */ +export async function clearError(store: StoreLike): Promise { + try { + await store.put(LAST_ERROR_KEY, null); + } catch { + /* diagnostics are non-fatal */ + } +} + export function messageOf(e: unknown): string { if (e && typeof e === "object" && "message" in e) return String((e as { message: unknown }).message); return String(e); @@ -212,6 +687,178 @@ export function truncate(s: string, n: number): string { return s.length <= n ? s : `${s.slice(0, n - 1)}…`; } +function cleanIdPart(s: string): string { + return s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project"; +} + +export function mentalModelId(projectId: string): string { + return `bobbit-${cleanIdPart(projectId)}`; +} + +export function mentalModelTags(projectId: string): string[] { + const pid = pidOf(projectId); + return pid ? [`project:${pid}`, "bobbit", "kind:mental-model"] : ["bobbit", "kind:mental-model"]; +} + +export function mentalModelSourceQuery(projectId: string, maxTokens: number): string { + const pid = pidOf(projectId) ?? "this project"; + return `Current durable project state for ${pid}: key decisions, architecture, conventions, open threads, recent outcomes, and next actions. Prefer consolidated observations and durable facts. Limit to about ${Math.max(1, Math.floor(maxTokens))} tokens.`; +} + +export interface MentalModelRefreshRecord { + lastAttemptAt?: number; + lastSuccessAt?: number; + operationId?: string; +} + +export function mentalModelRefreshKey(cfg: EffectiveConfig, projectId: string): string { + return `${MENTAL_MODEL_REFRESH_PREFIX}${cfg.namespace}:${cfg.bank}:${mentalModelId(projectId)}`; +} + +export async function shouldRefreshMentalModel(store: StoreLike | null, cfg: EffectiveConfig, projectId: string, now: number): Promise { + if (!cfg.mentalModelEnabled) return false; + if (!Number.isFinite(cfg.mentalModelRefreshEveryMs) || cfg.mentalModelRefreshEveryMs <= 0) return true; + if (!store) return true; + try { + const rec = await store.get(mentalModelRefreshKey(cfg, projectId)); + const last = rec && typeof rec.lastAttemptAt === "number" ? rec.lastAttemptAt : rec && typeof rec.lastSuccessAt === "number" ? rec.lastSuccessAt : 0; + return now - last >= cfg.mentalModelRefreshEveryMs; + } catch { + return true; + } +} + +export async function recordMentalModelRefresh(store: StoreLike | null, cfg: EffectiveConfig, projectId: string, rec: MentalModelRefreshRecord): Promise { + if (!store) return; + try { + await store.put(mentalModelRefreshKey(cfg, projectId), rec); + } catch { + /* best-effort cadence cache */ + } +} + +export function observationScopesForProject(projectId?: string): string[][] | undefined { + const pid = pidOf(projectId); + return pid ? [[`project:${pid}`]] : undefined; +} + +function uniqueEntities(items: EntityInput[]): EntityInput[] { + const seen = new Set(); + const out: EntityInput[] = []; + for (const item of items) { + const text = typeof item.text === "string" ? item.text.trim() : ""; + if (!text) continue; + const type = typeof item.type === "string" && item.type.trim().length > 0 ? item.type.trim() : undefined; + const key = `${type ?? ""}\0${text}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(type ? { text, type } : { text }); + } + return out; +} + +function stringArray(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim().length > 0).map((x) => x.trim()) : []; +} + +export function entitiesFromContext(ctx: unknown): EntityInput[] | undefined { + if (!isObj(ctx)) return undefined; + const files = [...stringArray(ctx.files), ...stringArray(ctx.touchedFiles)]; + const components = stringArray(ctx.components); + const entities = uniqueEntities([ + ...files.map((text) => ({ text, type: "file" })), + ...components.map((text) => ({ text, type: "component" })), + ]); + return entities.length > 0 ? entities : undefined; +} + +export function entitiesFromOutcomeBody(body: unknown): EntityInput[] | undefined { + if (!isObj(body)) return undefined; + const explicit = Array.isArray(body.entities) + ? body.entities.filter((e): e is EntityInput => isObj(e) && typeof e.text === "string").map((e) => ({ text: e.text, ...(typeof e.type === "string" ? { type: e.type } : {}) })) + : []; + const files = [...stringArray(body.files), ...stringArray(body.touchedFiles)]; + const components = stringArray(body.components); + const entities = uniqueEntities([ + ...explicit, + ...files.map((text) => ({ text, type: "file" })), + ...components.map((text) => ({ text, type: "component" })), + ]); + return entities.length > 0 ? entities : undefined; +} + +export function outcomeTags(input: OutcomeDigestInput, extra?: Tags): Tags { + const tags: Tags = { kind: "outcome", bobbit: "true", ...(extra ?? {}), ...(input.tags ?? {}) }; + const pid = pidOf(input.projectId); + if (pid) tags.project = pid; + if (typeof input.goalId === "string" && input.goalId.trim().length > 0) tags.goal = input.goalId.trim(); + if (input.pr !== undefined && String(input.pr).trim().length > 0) tags.pr = String(input.pr).trim(); + return tags; +} + +export function buildOutcomeDigest(input: OutcomeDigestInput): OutcomeDigest { + const timestamp = input.timestamp && !Number.isNaN(Date.parse(input.timestamp)) ? input.timestamp : new Date().toISOString(); + const goalOrPr = input.goalId ? `goal:${input.goalId}` : input.pr !== undefined ? `pr:${input.pr}` : `manual:${cleanIdPart(input.title ?? timestamp)}`; + const lines: string[] = []; + lines.push(input.title ? `Outcome: ${input.title}` : `Outcome digest for ${goalOrPr}`); + if (input.branch) lines.push(`Branch: ${input.branch}`); + if (input.mergeTarget) lines.push(`Merge target: ${input.mergeTarget}`); + if (input.content && input.content.trim()) lines.push(input.content.trim()); + if (input.achievements?.length) lines.push(`Achievements:\n${input.achievements.map((x) => `- ${x}`).join("\n")}`); + if (input.decisions?.length) lines.push(`Decisions:\n${input.decisions.map((x) => `- ${x}`).join("\n")}`); + const files = [...stringArray(input.files), ...stringArray(input.touchedFiles)]; + if (files.length) lines.push(`Files/components:\n${[...files, ...stringArray(input.components)].map((x) => `- ${x}`).join("\n")}`); + const entities = entitiesFromOutcomeBody(input); + return { + content: lines.join("\n\n"), + documentId: input.goalId ? `outcome:${input.goalId}` : input.pr !== undefined ? `outcome:pr:${input.pr}` : `outcome:${goalOrPr}`, + tags: outcomeTags(input), + ...(entities ? { entities } : {}), + timestamp, + ...(observationScopesForProject(input.projectId) ? { observationScopes: observationScopesForProject(input.projectId) } : {}), + }; +} + +export function currentQueryTimestamp(enabled: boolean, now = new Date()): string | undefined { + return enabled ? now.toISOString() : undefined; +} + +export function directivesSignature(cfg: EffectiveConfig): string { + return JSON.stringify({ ns: cfg.namespace, bank: cfg.bank, mode: cfg.directiveApplyMode, version: cfg.directiveSetVersion, directives: cfg.directives }); +} + +export function reflectInstructionPrefix(cfg: EffectiveConfig): string { + if (cfg.directivesEnabled && cfg.directiveApplyMode !== "disabled") return ""; + return DEFAULT_BOBBIT_DIRECTIVES[0]?.content ?? ""; +} + +export async function applyDirectives(store: StoreLike | null, client: HindsightClientLike, cfg: EffectiveConfig): Promise { + if (!cfg.directivesEnabled || cfg.directiveApplyMode === "disabled") return; + if (typeof client.listDirectives !== "function" || typeof client.createDirective !== "function") return; + const key = `${DIRECTIVES_APPLIED_PREFIX}${cfg.namespace}:${cfg.bank}`; + const sig = directivesSignature(cfg); + if (store) { + try { + if ((await store.get(key)) === sig) return; + } catch { + /* fall through */ + } + } + try { + const existing = await client.listDirectives(cfg.bank); + for (const directive of cfg.directives) { + if (!directive.name.startsWith("bobbit-")) continue; + const current = existing.items.find((d) => d.name === directive.name); + const payload = { ...directive, isActive: true, tags: [...new Set([...(directive.tags ?? []), "bobbit"])] }; + if (current?.id && typeof client.updateDirective === "function") await client.updateDirective(cfg.bank, current.id, payload); + else await client.createDirective(cfg.bank, payload); + } + if (store) await store.put(key, sig); + } catch (e) { + if (store) await recordError(store, e); + } +} + // ── Config validation (routes `config` SET). ────────────────────────────────── export interface ConfigValidation { ok: boolean; @@ -228,10 +875,11 @@ export function validateConfigOverrides(body: unknown): ConfigValidation { const value: Record = {}; if ("mode" in body) { - if (body.mode === "external" || body.mode === "managed") value.mode = body.mode; - else errors.push("mode must be 'external' or 'managed'"); + if (body.mode === "external" || body.mode === "managed" || body.mode === "managed-external-postgres") value.mode = body.mode; + else errors.push("mode must be 'external', 'managed', or 'managed-external-postgres'"); } - for (const key of ["externalUrl", "apiKey"] as const) { + // Optional secret/string fields; "" (or null) clears. + for (const key of ["externalUrl", "apiKey", "externalDatabaseUrl", "llmApiKey"] as const) { if (key in body) { const v = body[key]; if (typeof v === "string") value[key] = v; // "" clears @@ -239,7 +887,23 @@ export function validateConfigOverrides(body: unknown): ConfigValidation { else errors.push(`${key} must be a string`); } } - for (const key of ["bank", "namespace"] as const) { + // Optional NON-secret dashboard URL; "" (or null) clears. When non-empty it must + // parse as an http(s) URL (it is opened by the UI, never dialed by the client). + if ("uiUrl" in body) { + const v = body.uiUrl; + if (v === null || v === "") value.uiUrl = ""; + else if (typeof v === "string") { + let parsed: URL | undefined; + try { + parsed = new URL(v); + } catch { + parsed = undefined; + } + if (parsed && (parsed.protocol === "http:" || parsed.protocol === "https:")) value.uiUrl = v; + else errors.push("uiUrl must be an http(s) URL"); + } else errors.push("uiUrl must be a string"); + } + for (const key of ["bank", "namespace", "dataDir"] as const) { if (key in body) { const v = body[key]; if (typeof v === "string" && v.trim().length > 0) value[key] = v.trim(); @@ -250,36 +914,308 @@ export function validateConfigOverrides(body: unknown): ConfigValidation { if (body.recallScope === "project" || body.recallScope === "all") value.recallScope = body.recallScope; else errors.push("recallScope must be 'project' or 'all'"); } + if ("tagsMatch" in body) { + if (body.tagsMatch === "any" || body.tagsMatch === "any_strict") value.tagsMatch = body.tagsMatch; + else errors.push("tagsMatch must be 'any' or 'any_strict'"); + } + if ("recallTypes" in body) { + const v = body.recallTypes; + if (Array.isArray(v) && v.length > 0 && v.every(isRecallType)) value.recallTypes = [...new Set(v)]; + else errors.push("recallTypes must be a non-empty array of 'observation'|'world'|'experience'"); + } + for (const key of ["recallQueryTimestampEnabled", "mentalModelEnabled", "directivesEnabled", "retainQueueHealthGate", "retainQueueLlmHealthGate"] as const) { + if (key in body) { + if (typeof body[key] === "boolean") value[key] = body[key]; + else errors.push(`${key} must be a boolean`); + } + } + if ("directiveApplyMode" in body) { + if (isDirectiveApplyMode(body.directiveApplyMode)) value.directiveApplyMode = body.directiveApplyMode; + else errors.push("directiveApplyMode must be 'disabled', 'bank-wide-explicit-opt-in', or 'scoped-if-supported'"); + } + if ("directiveSetVersion" in body) { + if (typeof body.directiveSetVersion === "string" && body.directiveSetVersion.trim().length > 0) value.directiveSetVersion = body.directiveSetVersion.trim(); + else errors.push("directiveSetVersion must be a non-empty string"); + } + if ("directives" in body) { + const parsed = asDirectives(body.directives, []); + if (parsed.length > 0) value.directives = parsed; + else errors.push("directives must be a non-empty list of { name, content }"); + } + // Configurable bank-mission strings ("" keeps the default — see resolveConfig). + for (const key of ["retainMission", "observationsMission", "reflectMission"] as const) { + if (key in body) { + if (typeof body[key] === "string") value[key] = body[key]; + else errors.push(`${key} must be a string`); + } + } for (const key of ["autoRecall", "autoRetain"] as const) { if (key in body) { if (typeof body[key] === "boolean") value[key] = body[key]; else errors.push(`${key} must be a boolean`); } } - for (const key of ["recallBudget", "timeoutMs"] as const) { + if ("retainEveryNTurns" in body) { + const v = body.retainEveryNTurns; + if (typeof v === "number" && Number.isFinite(v) && v >= 1) value.retainEveryNTurns = Math.floor(v); + else errors.push("retainEveryNTurns must be a number >= 1"); + } + if ("retainMaxDelayMs" in body) { + const v = body.retainMaxDelayMs; + if (typeof v === "number" && Number.isFinite(v) && v >= 0) value.retainMaxDelayMs = Math.floor(v); + else errors.push("retainMaxDelayMs must be a number >= 0 (0 disables the time-based flush)"); + } + if ("retainOverlapTurns" in body) { + const v = body.retainOverlapTurns; + if (typeof v === "number" && Number.isFinite(v) && v >= 0) value.retainOverlapTurns = Math.floor(v); + else errors.push("retainOverlapTurns must be a number >= 0"); + } + for (const key of ["recallBudget", "recallMaxInputChars", "timeoutMs", "mentalModelMaxTokens", "mentalModelRecallMaxTokens"] as const) { if (key in body) { const v = body[key]; if (typeof v === "number" && Number.isFinite(v) && v > 0) value[key] = v; else errors.push(`${key} must be a positive number`); } } + for (const key of ["mentalModelRefreshEveryMs", "retainQueueDrainMaxPerHook", "retainQueueShutdownMax", "retainQueueBatchPauseMs"] as const) { + if (key in body) { + const v = body[key]; + if (typeof v === "number" && Number.isFinite(v) && v >= 0) value[key] = Math.floor(v); + else errors.push(`${key} must be a number >= 0`); + } + } + + return errors.length > 0 ? { ok: false, errors } : { ok: true, value }; +} +/** Validate a PER-PROJECT config overlay. Only memory-quality keys may be set per + * project (`recallScope`, `bank`, `tagsMatch`, `recallBudget`, `recallTypes`); a + * project overlay can NEVER change `mode`, `externalUrl`, secrets, or runtime + * deployment (those stay server-global). A key set to `null`/`""` is cleared + * (omitted from the result), so a full overlay write replaces the stored one. */ +export function validateProjectOverride(body: unknown): ConfigValidation { + if (!isObj(body)) return { ok: false, errors: ["projectOverride must be an object"] }; + const errors: string[] = []; + const value: Record = {}; + const cleared = (v: unknown): boolean => v === null || v === ""; + + if ("recallScope" in body && !cleared(body.recallScope)) { + if (body.recallScope === "project" || body.recallScope === "all") value.recallScope = body.recallScope; + else errors.push("recallScope must be 'project' or 'all'"); + } + if ("bank" in body && !cleared(body.bank)) { + if (typeof body.bank === "string" && body.bank.trim().length > 0) value.bank = body.bank.trim(); + else errors.push("bank must be a non-empty string"); + } + if ("tagsMatch" in body && !cleared(body.tagsMatch)) { + if (body.tagsMatch === "any" || body.tagsMatch === "any_strict") value.tagsMatch = body.tagsMatch; + else errors.push("tagsMatch must be 'any' or 'any_strict'"); + } + if ("recallBudget" in body && !cleared(body.recallBudget)) { + const v = body.recallBudget; + if (typeof v === "number" && Number.isFinite(v) && v > 0) value.recallBudget = v; + else errors.push("recallBudget must be a positive number"); + } + if ("recallTypes" in body && body.recallTypes !== null) { + const v = body.recallTypes; + if (Array.isArray(v) && v.length > 0 && v.every(isRecallType)) value.recallTypes = [...new Set(v)]; + else errors.push("recallTypes must be a non-empty array of 'observation'|'world'|'experience'"); + } return errors.length > 0 ? { ok: false, errors } : { ok: true, value }; } -/** Redact secrets for the `config` GET surface — apiKey collapses to a boolean. */ +/** Redact secrets for the `config` GET surface — every secret field collapses to a + * `Set` boolean and the raw value is never echoed. */ export function redactConfig(cfg: EffectiveConfig): Record { - const { apiKey, ...rest } = cfg; - return { ...rest, apiKeySet: typeof apiKey === "string" && apiKey.length > 0 }; + const { apiKey, externalDatabaseUrl, llmApiKey, ...rest } = cfg; + return { + ...rest, + apiKeySet: typeof apiKey === "string" && apiKey.length > 0, + externalDatabaseUrlSet: typeof externalDatabaseUrl === "string" && externalDatabaseUrl.length > 0, + llmApiKeySet: typeof llmApiKey === "string" && llmApiKey.length > 0, + }; } -/** Effective config for the routes (store overrides over flat defaults). */ -export async function loadEffectiveConfig(store: StoreLike): Promise { - let stored: unknown; +async function readStored(store: StoreLike, key: string): Promise | undefined> { try { - stored = await store.get(CONFIG_KEY); + const v = await store.get(key); + return isObj(v) ? v : undefined; } catch { - stored = undefined; + return undefined; } - return resolveConfig({ ...CONFIG_DEFAULTS, ...(isObj(stored) ? stored : {}) }); +} + +const pidOf = (projectId?: string): string | undefined => + typeof projectId === "string" && projectId.trim().length > 0 ? projectId.trim() : undefined; + +/** Read + validate the per-project config overlay (safe memory-quality keys only). + * Returns undefined when there is no project id or no usable stored overlay. */ +export async function loadProjectOverride(store: StoreLike, projectId?: string): Promise | undefined> { + const pid = pidOf(projectId); + if (!pid) return undefined; + const raw = await readStored(store, projectConfigKey(pid)); + if (!raw) return undefined; + const v = validateProjectOverride(raw); + return v.ok && v.value && Object.keys(v.value).length > 0 ? v.value : undefined; +} + +/** Effective config for the routes. Resolution precedence (low → high): + * CONFIG_DEFAULTS → global store config (CONFIG_KEY) → per-project overlay. */ +export async function loadEffectiveConfig(store: StoreLike, projectId?: string): Promise { + const global = (await readStored(store, CONFIG_KEY)) ?? {}; + const overlay = (await loadProjectOverride(store, projectId)) ?? {}; + return resolveConfig({ ...CONFIG_DEFAULTS, ...global, ...overlay }); +} + +/** Overlay the per-project config onto an ALREADY-resolved base (the provider's + * `ctx.config`, which the host has merged from yaml defaults + global store). The + * overlay only carries safe memory-quality keys, so deployment/activation fields + * (mode/externalUrl) are never changed by it. */ +export async function overlayProjectConfig( + base: EffectiveConfig, + store: StoreLike | null, + projectId?: string, +): Promise { + if (!store) return base; + const overlay = await loadProjectOverride(store, projectId); + if (!overlay) return base; + return resolveConfig({ ...base, ...overlay }); +} + +// ── Bank mission (durable-knowledge steering) ───────────────────────────────── + +/** Non-empty mission updates in the snake_case REST shape (empty ⇒ field omitted). */ +export function bankMissionUpdates(cfg: EffectiveConfig): BankMissionUpdates { + const u: BankMissionUpdates = {}; + if (cfg.retainMission && cfg.retainMission.trim().length > 0) u.retain_mission = cfg.retainMission; + if (cfg.observationsMission && cfg.observationsMission.trim().length > 0) u.observations_mission = cfg.observationsMission; + if (cfg.reflectMission && cfg.reflectMission.trim().length > 0) u.reflect_mission = cfg.reflectMission; + return u; +} + +/** Stable signature of the mission config for the applied-cache (skip redundant PATCH). */ +export function missionSignature(cfg: EffectiveConfig): string { + return JSON.stringify({ ns: cfg.namespace, bank: cfg.bank, ...bankMissionUpdates(cfg) }); +} + +/** Idempotently apply the bank-config missions (PATCH …/config) after ensureBank. + * Caches the last-applied signature per namespace:bank in the pack store and + * re-PATCHes ONLY when it changes (no extra call per turn). Best-effort: a PATCH + * failure records a diagnostic but NEVER blocks retain — the caller proceeds. */ +export async function applyBankMission(store: StoreLike | null, client: HindsightClientLike, cfg: EffectiveConfig): Promise { + const updates = bankMissionUpdates(cfg); + if (Object.keys(updates).length === 0 || typeof client.updateBankConfig !== "function") return; + const key = `${BANK_CONFIG_APPLIED_PREFIX}${cfg.namespace}:${cfg.bank}`; + const sig = missionSignature(cfg); + if (store) { + try { + if ((await store.get(key)) === sig) return; + } catch { + /* fall through and (re)apply */ + } + } + try { + await client.updateBankConfig(cfg.bank, updates); + if (store) { + try { + await store.put(key, sig); + } catch { + /* best-effort cache */ + } + } + } catch (e) { + if (store) await recordError(store, e); // diagnostic only — never blocks retain + } +} + +// ── Auto-retain batching (durable per-session pending buffer) ───────────────── +// +// Turns are BATCHED, never sampled: each `afterTurn` appends a compact summary to +// a durable per-session buffer and an aggregate retain (containing ALL pending +// primary turns) is flushed when the buffer reaches `retainEveryNTurns`, or when +// the oldest pending turn ages past `retainMaxDelayMs` (a hook-observed timeout — +// no provider-local timers). After a flush the last `retainOverlapTurns` summaries +// are carried forward as bounded OVERLAP context and the primary turns are cleared +// so the count always advances and the buffer never grows unbounded. + +/** One buffered turn summary + its capture timestamp (for the max-delay flush). */ +export interface PendingTurn { + summary: string; + ts: number; +} + +/** Durable per-session retain buffer: primary `turns` (count toward the batch) plus + * `overlap` carry-forward summaries from the previous flush (do NOT count). */ +export interface PendingBuffer { + turns: PendingTurn[]; + overlap: string[]; +} + +/** Separator between turn summaries inside a flushed aggregate retain. */ +export const AGGREGATE_SEPARATOR = "\n\n---\n\n"; + +export function pendingKey(sessionId: string): string { + return `${RETAIN_PENDING_PREFIX}${sessionId}`; +} + +function asPendingTurns(v: unknown): PendingTurn[] { + if (!Array.isArray(v)) return []; + return v + .filter((t): t is PendingTurn => isObj(t) && typeof (t as { summary?: unknown }).summary === "string") + .map((t) => ({ summary: String(t.summary), ts: typeof t.ts === "number" && Number.isFinite(t.ts) ? t.ts : 0 })); +} + +/** Read the durable pending buffer (tolerant of an absent/legacy/garbled value). */ +export async function loadPending(store: StoreLike, sessionId: string): Promise { + try { + const v = await store.get(pendingKey(sessionId)); + if (isObj(v)) { + return { + turns: asPendingTurns((v as PendingBuffer).turns), + overlap: Array.isArray((v as PendingBuffer).overlap) ? (v as PendingBuffer).overlap.filter((s) => typeof s === "string") : [], + }; + } + } catch { + /* fall through to empty */ + } + return { turns: [], overlap: [] }; +} + +export async function savePending(store: StoreLike, sessionId: string, buf: PendingBuffer): Promise { + try { + await store.put(pendingKey(sessionId), buf); + } catch { + /* best-effort durable buffer */ + } +} + +/** Whether the pending buffer should flush now: the batch is full (turns >= + * everyN), OR (time-based) the oldest pending turn has aged past maxDelayMs. + * Never flushes an empty buffer; `maxDelayMs <= 0` disables the time-based flush. */ +export function shouldFlushPending(buf: PendingBuffer, everyN: number, maxDelayMs: number, now: number): boolean { + if (buf.turns.length === 0) return false; + const n = Number.isFinite(everyN) && everyN >= 1 ? Math.floor(everyN) : 1; + if (buf.turns.length >= n) return true; + if (Number.isFinite(maxDelayMs) && maxDelayMs > 0) { + const oldest = buf.turns[0]?.ts ?? now; + if (now - oldest >= maxDelayMs) return true; + } + return false; +} + +/** Build the aggregate retain content: overlap context (from the previous flush) + * followed by every pending primary turn, joined by {@link AGGREGATE_SEPARATOR}. */ +export function buildAggregateContent(buf: PendingBuffer): string { + const parts: string[] = []; + if (buf.overlap.length > 0) parts.push(`Earlier context (overlap):${AGGREGATE_SEPARATOR}${buf.overlap.join(AGGREGATE_SEPARATOR)}`); + for (const t of buf.turns) parts.push(t.summary); + return parts.join(AGGREGATE_SEPARATOR); +} + +/** The overlap carry-forward for the NEXT batch: the last `overlapTurns` summaries + * of the just-flushed primary turns (bounded — never the previous overlap). */ +export function nextOverlap(turns: PendingTurn[], overlapTurns: number): string[] { + const k = Number.isFinite(overlapTurns) && overlapTurns >= 1 ? Math.floor(overlapTurns) : 0; + if (k <= 0) return []; + return turns.slice(-k).map((t) => t.summary); } diff --git a/market-packs/hindsight/tools/hindsight/extension.ts b/market-packs/hindsight/tools/hindsight/extension.ts new file mode 100644 index 000000000..54e2e0ffe --- /dev/null +++ b/market-packs/hindsight/tools/hindsight/extension.ts @@ -0,0 +1,490 @@ +/** + * Hindsight agent tools (P5) — `hindsight_recall`, `hindsight_retain`, + * `hindsight_reflect`, `hindsight_retain_outcome`, `hindsight_invalidate`. + * + * These are PACK-OWNED tools: they ship with the built-in `hindsight` market + * pack (`pack.yaml.contents.tools: [hindsight]`), so disabling the pack at any + * scope removes them from session tool resolution. + * + * The tools NEVER talk to Hindsight directly and NEVER construct a + * HindsightClient or read provider config. Instead each tool: + * 1. mints a tool-bound SERVER-MINTED surface token via + * `POST /api/ext/surface-token` ({ sessionId, tool }), then + * 2. dispatches the pack's own route via `POST /api/ext/route/` with the + * minted `surfaceToken`. + * The route (market-packs/hindsight/src/routes.ts) owns config merge, bank + * resolution (single shared bank, default `bobbit`), external/managed-mode + * handling, dormancy, and scope→tag mapping. This keeps the agent surface thin + * and routes all authorization through the existing surface-token + tool-guard + * path. + * + * CREDENTIALS: read the on-disk gateway URL + token (disk first, env fallback), + * mirroring `defaults/tools/_shared/gateway.ts`. The logic is inlined rather than + * imported because the relative depth from this pack file to `defaults/tools` is + * NOT stable across the repo-source layout and the shipped `dist/.../builtin-packs` + * layout. + */ + +import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; + +// ── Gateway credential resolution (mirrors defaults/tools/_shared/gateway.ts) ── + +function diskStateDir(): string { + return process.env.BOBBIT_DIR + ? path.join(process.env.BOBBIT_DIR, "state") + : path.join(homedir(), ".pi"); +} + +function diskTokenPath(): string { + const tokenFile = process.env.BOBBIT_DIR ? "token" : "gateway-token"; + return path.join(diskStateDir(), tokenFile); +} + +function diskUrlPath(): string { + return path.join(diskStateDir(), "gateway-url"); +} + +type Creds = { token: string; baseUrl: string }; + +/** Disk-first, env-fallback creds resolver. Returns `{ error }` on miss. */ +function readCreds(): Creds | { error: string } { + try { + const token = fs.readFileSync(diskTokenPath(), "utf-8").trim(); + const baseUrl = fs.readFileSync(diskUrlPath(), "utf-8").trim().replace(/\/+$/, ""); + if (token && baseUrl) return { token, baseUrl }; + } catch { + // Disk read failed; fall through to env. + } + const envToken = process.env.BOBBIT_TOKEN; + const envUrl = process.env.BOBBIT_GATEWAY_URL; + if (envToken && envUrl) return { token: envToken, baseUrl: envUrl.replace(/\/+$/, "") }; + return { error: "BOBBIT credentials not found on disk or in env" }; +} + +const TRANSIENT_RE = /ECONNRESET|ECONNREFUSED|EPIPE|socket hang up|UND_ERR_SOCKET|fetch failed/i; + +function isTransient(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const cause = (err as { cause?: unknown }).cause; + const causeMsg = cause instanceof Error ? cause.message : ""; + const causeCode = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) + : ""; + return TRANSIENT_RE.test([err.message, causeMsg, causeCode].filter(Boolean).join(" ")); +} + +/** Authenticated JSON POST against the gateway, with light transient-retry + + * one creds refresh on 401. Throws on non-2xx with the server `error` field. */ +async function apiPost( + creds: Creds, + urlPath: string, + body: unknown, + sessionId: string, + signal?: AbortSignal, +): Promise { + const maxAttempts = 4; + let used = creds; + let didRefresh = false; + let lastErr: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const resp = await fetch(`${used.baseUrl}${urlPath}`, { + method: "POST", + headers: { + Authorization: `Bearer ${used.token}`, + "Content-Type": "application/json", + "x-bobbit-session-id": sessionId, + }, + body: JSON.stringify(body), + signal, + }); + if (resp.status === 401 && !didRefresh) { + didRefresh = true; + const fresh = readCreds(); + if (!("error" in fresh)) { + used = fresh; + continue; + } + } + const text = await resp.text(); + let data: unknown; + try { data = JSON.parse(text); } catch { data = text; } + if (!resp.ok) { + const msg = typeof data === "object" && data !== null && "error" in data + ? String((data as Record).error) + : `HTTP ${resp.status}: ${text}`; + throw new Error(msg); + } + return data; + } catch (err) { + lastErr = err; + if (!isTransient(err) || attempt === maxAttempts - 1) throw err; + await new Promise((r) => setTimeout(r, 250 * Math.pow(2, attempt))); + } + } + throw lastErr; +} + +/** + * Mint a tool-bound surface token, then dispatch the pack route. The server + * DERIVES {packId, tool} from the minted token and enforces allowedTools + own + * session — the route body never carries a pack id. + */ +async function callRoute( + toolName: string, + routeName: string, + routeBody: Record, + signal?: AbortSignal, +): Promise { + const creds = readCreds(); + if ("error" in creds) throw new Error(creds.error); + const sessionId = process.env.BOBBIT_SESSION_ID; + if (!sessionId) throw new Error("BOBBIT_SESSION_ID is not set"); + + const mint = (await apiPost(creds, "/api/ext/surface-token", { sessionId, tool: toolName }, sessionId, signal)) as { + token?: string; + }; + const surfaceToken = mint?.token; + if (!surfaceToken) throw new Error("surface-token: empty response"); + + return apiPost( + creds, + `/api/ext/route/${encodeURIComponent(routeName)}`, + { sessionId, surfaceToken, init: { method: "POST", body: routeBody } }, + sessionId, + signal, + ); +} + +const SCOPE_DESC = "Scope: project=this project+shared/global by default; all=whole shared bank."; +const TAGS_DESC = "Optional simple key→value tag filter for a targeted query (no boolean DSL)."; +const FACT_TYPE = Type.Union([Type.Literal("observation"), Type.Literal("world"), Type.Literal("experience")]); +const JSON_SCHEMA_PARAM = Type.Record(Type.String(), Type.Any(), { + description: "Optional JSON Schema object. When supplied, Hindsight may return structuredOutput.", +}); +const ENTITY_PARAM = Type.Object({ + text: Type.String({ description: "Entity text, e.g. a file path or component name." }), + type: Type.Optional(Type.String({ description: "Entity type, e.g. file or component." })), +}); + +interface ToolError { + content: Array<{ type: "text"; text: string }>; + details: Record; + isError: true; +} + +function errorResult(text: string, details: Record): ToolError { + return { content: [{ type: "text" as const, text }], details, isError: true }; +} + +const extension: ExtensionFactory = (pi) => { + // ── hindsight_recall ── + pi.registerTool({ + name: "hindsight_recall", + label: "Hindsight Recall", + description: "Recall relevant memories from the Hindsight bank for a query.", + promptSnippet: "hindsight_recall: Recall relevant long-term memories for a query.", + promptGuidelines: [ + "Use hindsight_recall to fetch durable context (past decisions, preferences, project facts) before acting.", + "scope 'project' recalls this project plus shared/global memories by default; 'all' deliberately searches the whole shared bank.", + ], + parameters: Type.Object({ + query: Type.String({ description: "What to recall." }), + scope: Type.Optional( + Type.Union([Type.Literal("project"), Type.Literal("all")], { description: SCOPE_DESC }), + ), + tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: TAGS_DESC })), + }), + async execute( + _toolCallId: string, + params: { query?: string; scope?: "project" | "all"; tags?: Record }, + signal?: AbortSignal, + ) { + const query = typeof params.query === "string" ? params.query.trim() : ""; + if (!query) return errorResult("query is required", { query: params.query }); + let res: { configured?: boolean; memories?: unknown[]; error?: string }; + try { + res = (await callRoute( + "hindsight_recall", + "recall", + { query, ...(params.scope ? { scope: params.scope } : {}), ...(params.tags ? { tags: params.tags } : {}) }, + signal, + )) as typeof res; + } catch (e) { + return errorResult(`Recall failed: ${(e as Error).message}`, { query }); + } + if (res?.error) return errorResult(`Recall error: ${res.error}`, { query, configured: res.configured }); + const memories = Array.isArray(res?.memories) ? res.memories : []; + if (res?.configured === false) { + return { + content: [{ type: "text" as const, text: "Hindsight is not configured; no memories available." }], + details: { query, configured: false, count: 0 }, + }; + } + if (memories.length === 0) { + return { + content: [{ type: "text" as const, text: "No relevant memories found." }], + details: { query, configured: true, count: 0 }, + }; + } + const text = memories + .map((m, i) => { + // Recall results follow the route/client shape `{ id, text, score, ... }` + // (hindsight-client.ts RecallMemory). Prefer the human-readable `text`; + // fall back to a legacy `content` field, then to JSON only as a last + // resort so a successful recall shows memory prose, not a JSON blob. + const mm = m as { text?: unknown; content?: unknown }; + const body = + typeof mm?.text === "string" && mm.text.length > 0 + ? mm.text + : typeof mm?.content === "string" && mm.content.length > 0 + ? mm.content + : JSON.stringify(m); + return `${i + 1}. ${body}`; + }) + .join("\n"); + return { + content: [{ type: "text" as const, text }], + details: { query, configured: true, count: memories.length, memories }, + }; + }, + }); + + // ── hindsight_retain ── + pi.registerTool({ + name: "hindsight_retain", + label: "Hindsight Retain", + description: "Persist a memory to the Hindsight bank for future recall.", + promptSnippet: "hindsight_retain: Save a durable memory for future recall.", + promptGuidelines: [ + "Use hindsight_retain to durably record a decision, preference, or fact worth remembering.", + "The route adds trusted kind/project/goal/session/agent context tags automatically when available.", + "scope 'project' tags the memory to this project; 'all' keeps it unscoped on the shared bank.", + ], + parameters: Type.Object({ + content: Type.String({ description: "The memory text to store." }), + scope: Type.Optional( + Type.Union([Type.Literal("project"), Type.Literal("all")], { description: SCOPE_DESC }), + ), + tags: Type.Optional( + Type.Record(Type.String(), Type.String(), { description: "Extra key/value tags; automatic context tags win on conflict." }), + ), + sync: Type.Optional(Type.Boolean({ description: "Wait for the write to be durable. Default false." })), + }), + async execute( + _toolCallId: string, + params: { content?: string; scope?: "project" | "all"; tags?: Record; sync?: boolean }, + signal?: AbortSignal, + ) { + const content = typeof params.content === "string" ? params.content.trim() : ""; + if (!content) return errorResult("content is required", {}); + let res: { ok?: boolean; configured?: boolean; error?: string }; + try { + res = (await callRoute( + "hindsight_retain", + "retain", + { + content, + ...(params.scope ? { scope: params.scope } : {}), + ...(params.tags ? { tags: params.tags } : {}), + ...(params.sync !== undefined ? { sync: params.sync } : {}), + }, + signal, + )) as typeof res; + } catch (e) { + return errorResult(`Retain failed: ${(e as Error).message}`, {}); + } + if (res?.ok) { + return { + content: [{ type: "text" as const, text: "Memory retained." }], + details: { ok: true, configured: true }, + }; + } + if (res?.configured === false) { + return errorResult("Hindsight is not configured; memory not retained.", { configured: false }); + } + return errorResult(`Retain failed: ${res?.error ?? "unknown error"}`, { configured: res?.configured }); + }, + }); + + // ── hindsight_reflect ── + pi.registerTool({ + name: "hindsight_reflect", + label: "Hindsight Reflect", + description: "Reflect over the Hindsight bank to synthesize an answer to a prompt.", + promptSnippet: "hindsight_reflect: Synthesize an answer from long-term memory.", + promptGuidelines: [ + "Use hindsight_reflect for a synthesized answer drawing on accumulated memory, not a raw recall list.", + "scope 'project' reflects over this project plus shared/global memories by default; 'all' deliberately reflects over the whole shared bank.", + ], + parameters: Type.Object({ + prompt: Type.String({ description: "The question to reflect on." }), + scope: Type.Optional( + Type.Union([Type.Literal("project"), Type.Literal("all")], { description: SCOPE_DESC }), + ), + tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: TAGS_DESC })), + responseSchema: Type.Optional(JSON_SCHEMA_PARAM), + factTypes: Type.Optional(Type.Array(FACT_TYPE, { description: "Optional Hindsight fact_types filter." })), + excludeMentalModels: Type.Optional(Type.Boolean({ description: "Exclude mental models from reflection context." })), + }), + async execute( + _toolCallId: string, + params: { + prompt?: string; + scope?: "project" | "all"; + tags?: Record; + responseSchema?: Record; + factTypes?: Array<"observation" | "world" | "experience">; + excludeMentalModels?: boolean; + }, + signal?: AbortSignal, + ) { + const prompt = typeof params.prompt === "string" ? params.prompt.trim() : ""; + if (!prompt) return errorResult("prompt is required", {}); + let res: { configured?: boolean; text?: string; structuredOutput?: unknown; error?: string }; + try { + res = (await callRoute( + "hindsight_reflect", + "reflect", + { + prompt, + ...(params.scope ? { scope: params.scope } : {}), + ...(params.tags ? { tags: params.tags } : {}), + ...(params.responseSchema ? { responseSchema: params.responseSchema } : {}), + ...(params.factTypes ? { factTypes: params.factTypes } : {}), + ...(params.excludeMentalModels !== undefined ? { excludeMentalModels: params.excludeMentalModels } : {}), + }, + signal, + )) as typeof res; + } catch (e) { + return errorResult(`Reflect failed: ${(e as Error).message}`, { prompt }); + } + if (res?.error) return errorResult(`Reflect error: ${res.error}`, { prompt, configured: res.configured }); + if (res?.configured === false) { + return { + content: [{ type: "text" as const, text: "Hindsight is not configured; nothing to reflect on." }], + details: { prompt, configured: false }, + }; + } + const text = typeof res?.text === "string" && res.text.length > 0 ? res.text : "(no reflection produced)"; + const hasStructured = res && Object.prototype.hasOwnProperty.call(res, "structuredOutput") && res.structuredOutput !== undefined; + const display = hasStructured + ? `${text}\n\n\`\`\`json\n${JSON.stringify(res.structuredOutput, null, 2)}\n\`\`\`` + : text; + return { + content: [{ type: "text" as const, text: display }], + details: { prompt, configured: true, ...(hasStructured ? { structuredOutput: res.structuredOutput } : {}) }, + }; + }, + }); + + // ── hindsight_retain_outcome ── + pi.registerTool({ + name: "hindsight_retain_outcome", + label: "Hindsight Retain Outcome", + description: "Persist a concise goal/PR outcome digest with stable replacement semantics.", + promptSnippet: "hindsight_retain_outcome: Save a goal/PR outcome digest for future recall.", + promptGuidelines: [ + "Use hindsight_retain_outcome only for completed work summaries or repair/re-emission of a missing outcome digest.", + "Provide goalId or pr so the route can use a stable document id and replace duplicates.", + ], + parameters: Type.Object({ + content: Type.String({ description: "Concise outcome digest: achievements, decisions, and follow-ups." }), + goalId: Type.Optional(Type.String({ description: "Goal id. Produces document_id outcome:." })), + pr: Type.Optional(Type.Union([Type.String(), Type.Number()], { description: "Pull request number/id. Used when goalId is absent." })), + files: Type.Optional(Type.Array(Type.String(), { description: "Touched files to attach as file entities." })), + components: Type.Optional(Type.Array(Type.String(), { description: "Touched components to attach as component entities." })), + decisions: Type.Optional(Type.Array(Type.String(), { description: "Key decisions to include in route metadata/context." })), + entities: Type.Optional(Type.Array(ENTITY_PARAM, { description: "Additional Hindsight entities." })), + tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Extra key/value tags; canonical outcome tags win." })), + timestamp: Type.Optional(Type.String({ description: "ISO event timestamp. Defaults to now." })), + }), + async execute( + _toolCallId: string, + params: { + content?: string; + goalId?: string; + pr?: string | number; + files?: string[]; + components?: string[]; + decisions?: string[]; + entities?: Array<{ text: string; type?: string }>; + tags?: Record; + timestamp?: string; + }, + signal?: AbortSignal, + ) { + const content = typeof params.content === "string" ? params.content.trim() : ""; + if (!content) return errorResult("content is required", {}); + if (!params.goalId && params.pr === undefined) return errorResult("goalId or pr is required", {}); + let res: { ok?: boolean; configured?: boolean; documentId?: string; error?: string }; + try { + res = (await callRoute( + "hindsight_retain_outcome", + "retain_outcome", + { + content, + ...(params.goalId ? { goalId: params.goalId } : {}), + ...(params.pr !== undefined ? { pr: params.pr } : {}), + ...(params.files ? { files: params.files } : {}), + ...(params.components ? { components: params.components } : {}), + ...(params.decisions ? { decisions: params.decisions } : {}), + ...(params.entities ? { entities: params.entities } : {}), + ...(params.tags ? { tags: params.tags } : {}), + ...(params.timestamp ? { timestamp: params.timestamp } : {}), + }, + signal, + )) as typeof res; + } catch (e) { + return errorResult(`Outcome retain failed: ${(e as Error).message}`, {}); + } + if (res?.ok) { + return { + content: [{ type: "text" as const, text: `Outcome retained${res.documentId ? ` (${res.documentId})` : ""}.` }], + details: { ok: true, configured: true, documentId: res.documentId }, + }; + } + if (res?.configured === false) return errorResult("Hindsight is not configured; outcome not retained.", { configured: false }); + return errorResult(`Outcome retain failed: ${res?.error ?? "unknown error"}`, { configured: res?.configured, documentId: res?.documentId }); + }, + }); + + // ── hindsight_invalidate ── + pi.registerTool({ + name: "hindsight_invalidate", + label: "Hindsight Invalidate", + description: "Invalidate a stale or incorrect Hindsight memory without deleting history.", + promptSnippet: "hindsight_invalidate: Mark a stale Hindsight memory invalid with a reason.", + promptGuidelines: [ + "Use hindsight_invalidate only when you have a specific memory id and a concrete reason it is stale or incorrect.", + "This is reversible curation; do not use it as a delete-all or cleanup tool.", + ], + parameters: Type.Object({ + id: Type.String({ description: "Hindsight memory id to invalidate." }), + reason: Type.String({ description: "Why the memory is stale or incorrect." }), + }), + async execute(_toolCallId: string, params: { id?: string; reason?: string }, signal?: AbortSignal) { + const id = typeof params.id === "string" ? params.id.trim() : ""; + const reason = typeof params.reason === "string" ? params.reason.trim() : ""; + if (!id) return errorResult("id is required", {}); + if (!reason) return errorResult("reason is required", { id }); + let res: { ok?: boolean; configured?: boolean; id?: string; error?: string }; + try { + res = (await callRoute("hindsight_invalidate", "invalidate", { id, reason }, signal)) as typeof res; + } catch (e) { + return errorResult(`Invalidate failed: ${(e as Error).message}`, { id }); + } + if (res?.ok) { + return { content: [{ type: "text" as const, text: `Memory invalidated (${res.id ?? id}).` }], details: { ok: true, configured: true, id: res.id ?? id } }; + } + if (res?.configured === false) return errorResult("Hindsight is not configured; memory not invalidated.", { configured: false, id }); + return errorResult(`Invalidate failed: ${res?.error ?? "unknown error"}`, { configured: res?.configured, id }); + }, + }); +}; + +export default extension; diff --git a/market-packs/hindsight/tools/hindsight/hindsight_invalidate.yaml b/market-packs/hindsight/tools/hindsight/hindsight_invalidate.yaml new file mode 100644 index 000000000..1c5535d9b --- /dev/null +++ b/market-packs/hindsight/tools/hindsight/hindsight_invalidate.yaml @@ -0,0 +1,44 @@ +name: hindsight_invalidate +description: "Invalidate a stale or incorrect Hindsight memory without deleting history." +summary: "Invalidate a memory" +params: [id, reason] +provider: + type: bobbit-extension + extension: extension.ts +group: Hindsight +docs: |- + Required: `id` and `reason`. + + Marks a specific Hindsight memory as invalidated with a reason. This is + reversible curation, not deletion: history remains available through the direct + Hindsight API. +detail_docs: >- + ## Purpose + + + `hindsight_invalidate` retires a known-stale or incorrect memory without + permanently deleting it. Use it only when you have the exact memory id and a + concrete reason. + + + ## Parameters + + + | Name | Type | Required | Description | + + |------|------|----------|-------------| + + | `id` | string | **Yes** | Hindsight memory id to invalidate. | + + | `reason` | string | **Yes** | Why the memory is stale or incorrect. | + + + ## Notes + + + - The tool dispatches the pack's `invalidate` route, which uses Hindsight's + `PATCH /memories/{id}` invalidation flow. + + - It never calls the destructive delete endpoint. + + - Unconfigured Hindsight returns a not-configured error (dormant). diff --git a/market-packs/hindsight/tools/hindsight/hindsight_recall.yaml b/market-packs/hindsight/tools/hindsight/hindsight_recall.yaml new file mode 100644 index 000000000..7dffff1e7 --- /dev/null +++ b/market-packs/hindsight/tools/hindsight/hindsight_recall.yaml @@ -0,0 +1,68 @@ +name: hindsight_recall +description: "Recall relevant memories from the Hindsight bank for a query." +summary: "Recall long-term memories for a query" +params: [query, scope?, tags?] +provider: + type: bobbit-extension + extension: extension.ts +group: Hindsight +docs: |- + Required: `query`. Optional: `scope` (`project` | `all`), `tags` (simple + key→value map). + + Fetches durable memories from the shared Hindsight bank. Omit `scope` for the + configured default (`project` = this project + shared/global memories); + `scope: all` searches every project in the bank. `scope: project` restricts to + this project (route adds a `project:` tag when a real project id is present) + while still surfacing untagged/global memories. Use `tags` only for a simple + targeted filter (e.g. `{ goal: "..." }` or `{ project: "..." }`) — compound + boolean `tag_groups` queries are a direct Hindsight API escape hatch documented + in docs/hindsight-memory.md. Returns a concise list plus the structured route + result under `details`. Dormant (unconfigured) Hindsight returns an empty + result, not an error. +detail_docs: >- + ## Purpose + + + `hindsight_recall` retrieves durable, long-term memories (past decisions, + preferences, project facts) from the single shared Hindsight bank so the agent + can ground its work in accumulated context. + + + The tool NEVER talks to Hindsight directly. It mints a tool-bound surface + token and dispatches the pack's `recall` route, which owns bank resolution + (default `bobbit`), external/managed-mode handling, dormancy, and the + scope→tag mapping. + + + ## Parameters + + + | Name | Type | Required | Description | + + |------|------|----------|-------------| + + | `query` | string | **Yes** | What to recall. | + + | `scope` | `project` \| `all` | No | `project` scopes to this project (plus + shared/global) via a project tag; `all` searches the whole bank. Defaults to the + pack's configured `recallScope` (`project`). | + + | `tags` | map | No | Simple key→value filter for a targeted query (e.g. `{ goal: + "..." }`). Merged additively with the scope filter. | + + + ## Notes + + + - Scope maps to tag filters on the SAME shared bank — never a different bank. + + - `project` scope returns this project's memories AND untagged/global ones + (`tags_match: any`), excluding only other projects; it adds a `project:` + filter only when the session has a real project id. + + - `tags` is intentionally a flat map. Compound boolean `tag_groups` queries are a + direct Hindsight data-plane API escape hatch (see docs/hindsight-memory.md), not + exposed to the agent. + + - Unconfigured Hindsight returns an empty list (dormant), not an error. diff --git a/market-packs/hindsight/tools/hindsight/hindsight_reflect.yaml b/market-packs/hindsight/tools/hindsight/hindsight_reflect.yaml new file mode 100644 index 000000000..b682f2e2a --- /dev/null +++ b/market-packs/hindsight/tools/hindsight/hindsight_reflect.yaml @@ -0,0 +1,80 @@ +name: hindsight_reflect +description: "Reflect over the Hindsight bank to synthesize an answer to a prompt." +summary: "Synthesize an answer from long-term memory" +params: [prompt, scope?, tags?, responseSchema?, factTypes?, excludeMentalModels?] +provider: + type: bobbit-extension + extension: extension.ts +group: Hindsight +docs: |- + Required: `prompt`. Optional: `scope` (`project` | `all`), `tags` (simple + key→value map), `responseSchema` (JSON Schema object), `factTypes`, and + `excludeMentalModels`. + + Asks Hindsight to synthesize an answer over the shared bank rather than + returning a raw recall list. Omit `scope` for the configured default + (`project` = this project + shared/global memories); `scope: all` reflects over + every project. `scope: project` filters to this project's memories (the route + adds a `project:` tag filter when a real project id is present) while still + including untagged/global. Use `tags` only for a simple targeted filter (e.g. + `{ goal: "..." }`) — compound `tag_groups` queries are a direct Hindsight API + escape hatch documented in docs/hindsight-memory.md. Scope maps to a tag filter + on the SAME shared bank — never a different bank. Returns the synthesized text + and, when `responseSchema` is supplied, any `structuredOutput` returned by + Hindsight. Dormant (unconfigured) Hindsight returns empty text, not an error. +detail_docs: >- + ## Purpose + + + `hindsight_reflect` produces a synthesized answer drawing on accumulated + long-term memory, as opposed to `hindsight_recall` which returns matching + memory records. + + + The tool NEVER talks to Hindsight directly. It mints a tool-bound surface + token and dispatches the pack's `reflect` route, which runs over the resolved + shared bank (default `bobbit`). + + + ## Parameters + + + | Name | Type | Required | Description | + + |------|------|----------|-------------| + + | `prompt` | string | **Yes** | The question to reflect on. | + + | `scope` | `project` \| `all` | No | `project` filters reflection to this + project's memories (plus shared/global) via a `project:` tag; `all` reflects + over the whole bank. Defaults to the pack's configured `recallScope` (`project`). + Maps to a tag filter on the SAME shared bank — never a different bank. | + + | `tags` | map | No | Simple key→value filter for a targeted reflection (e.g. `{ + goal: "..." }`). Merged additively with the scope filter. | + + | `responseSchema` | object | No | JSON Schema object. When supplied, Hindsight + may return `structuredOutput`; the tool displays it as fenced JSON and exposes it + in `details.structuredOutput`. | + + | `factTypes` | array | No | Optional Hindsight `fact_types` filter + (`observation`, `world`, `experience`). | + + | `excludeMentalModels` | boolean | No | Passes `exclude_mental_models` through to + Hindsight for schema-focused reflections. | + + + ## Notes + + + - Scope maps to a tag filter on the single resolved bank — no extra banks, no + direct Hindsight calls. + + - `project` scope reflects over this project's memories AND untagged/global ones + (`tags_match: any`); it adds a `project:` filter only when the session has a + real project id. + + - `tags` is a flat map; compound `tag_groups` queries are a direct data-plane API + escape hatch (see docs/hindsight-memory.md), not exposed to the agent. + + - Unconfigured Hindsight returns empty text (dormant), not an error. diff --git a/market-packs/hindsight/tools/hindsight/hindsight_retain.yaml b/market-packs/hindsight/tools/hindsight/hindsight_retain.yaml new file mode 100644 index 000000000..6629d4cb1 --- /dev/null +++ b/market-packs/hindsight/tools/hindsight/hindsight_retain.yaml @@ -0,0 +1,61 @@ +name: hindsight_retain +description: "Persist a memory to the Hindsight bank for future recall." +summary: "Save a durable memory for future recall" +params: [content, scope?, tags?, sync?] +provider: + type: bobbit-extension + extension: extension.ts +group: Hindsight +docs: |- + Required: `content`. Optional: `scope` (`project` | `all`), `tags` + (key/value, additive), `sync` (wait for durability). + + Stores a durable memory on the shared Hindsight bank with automatic trusted + context tags: `kind:manual`, plus project/goal/session/agent when available. + `scope: all` adds no project tag. User `tags` are additive and never change + the bank or override automatic tags. Returns success/error from the route. +detail_docs: >- + ## Purpose + + + `hindsight_retain` durably records a memory (a decision, preference, or fact + worth remembering) on the single shared Hindsight bank for later recall. + + + The tool NEVER talks to Hindsight directly. It mints a tool-bound surface + token and dispatches the pack's `retain` route, which owns bank resolution + (default `bobbit`), trusted auto-tagging (`kind:manual` plus available + project/goal/session/agent context), and the scope→tag mapping. + + + ## Parameters + + + | Name | Type | Required | Description | + + |------|------|----------|-------------| + + | `content` | string | **Yes** | The memory text to store. | + + | `scope` | `project` \| `all` | No | `project` tags the memory to this + project; `all` leaves it unscoped on the shared bank. Defaults to the pack's + configured `recallScope`. | + + | `tags` | object | No | Extra key/value tags, merged additively. Automatic context tags win on conflict. | + + | `sync` | boolean | No | Wait for the write to be durable. Default `false`. | + + + ## Notes + + + - Scope maps to a project TAG on the SAME shared bank — never a different + bank. + + - The project tag is only added for `scope: project` when the session has a real project id. + + - Goal, session, and agent tags are added from trusted server context when available. + + - User `tags` are additive and must not change the bank or override automatic context tags. + + - Unconfigured Hindsight returns a not-configured error (dormant). diff --git a/market-packs/hindsight/tools/hindsight/hindsight_retain_outcome.yaml b/market-packs/hindsight/tools/hindsight/hindsight_retain_outcome.yaml new file mode 100644 index 000000000..58485cdb1 --- /dev/null +++ b/market-packs/hindsight/tools/hindsight/hindsight_retain_outcome.yaml @@ -0,0 +1,68 @@ +name: hindsight_retain_outcome +description: "Persist a concise goal/PR outcome digest with stable replacement semantics." +summary: "Save an outcome digest" +params: [content, goalId?, pr?, files?, components?, decisions?, entities?, tags?, timestamp?] +provider: + type: bobbit-extension + extension: extension.ts +group: Hindsight +docs: |- + Required: `content` plus either `goalId` or `pr`. Optional: `files`, + `components`, `decisions`, `entities`, `tags`, and `timestamp`. + + Stores one concise outcome digest on the shared Hindsight bank using stable + replacement semantics: `document_id` is `outcome:` or `outcome:pr:`, + `update_mode` is `replace`, and the write is async. The route adds canonical + `kind:outcome`, `bobbit`, project, goal, and PR tags, attaches file/component + entities, and uses a project observation scope when the session has a project id. +detail_docs: >- + ## Purpose + + + `hindsight_retain_outcome` is a fallback/repair tool for completed goal or PR + summaries. Normal goal completion should emit the digest automatically; use this + tool when an agent needs to re-emit or repair a missing digest. + + + ## Parameters + + + | Name | Type | Required | Description | + + |------|------|----------|-------------| + + | `content` | string | **Yes** | Concise outcome digest: achievements, + decisions, files/components touched, and follow-ups. | + + | `goalId` | string | No* | Goal id. Produces `document_id: outcome:`. | + + | `pr` | string/number | No* | PR number/id. Used only when `goalId` is absent, + producing `document_id: outcome:pr:`. | + + | `files` | string[] | No | File paths attached as `file` entities. | + + | `components` | string[] | No | Components attached as `component` entities. | + + | `decisions` | string[] | No | Key decisions included in the route payload for + future clients/providers. | + + | `entities` | array | No | Additional entities, each `{ text, type? }`. | + + | `tags` | map | No | Extra key/value tags. Canonical outcome tags win on + conflict. | + + | `timestamp` | string | No | ISO event timestamp. Defaults to now. | + + + *Either `goalId` or `pr` is required. + + + ## Notes + + + - Scope maps to tags on the same shared bank; this tool never changes banks. + + - Re-emitting the same goal/PR replaces the prior digest instead of duplicating + it. + + - Unconfigured Hindsight returns a not-configured error (dormant). diff --git a/market-packs/pr-walkthrough/roles/pr-reviewer.yaml b/market-packs/pr-walkthrough/roles/pr-reviewer.yaml index 4487c4fa8..38e33239d 100644 --- a/market-packs/pr-walkthrough/roles/pr-reviewer.yaml +++ b/market-packs/pr-walkthrough/roles/pr-reviewer.yaml @@ -36,6 +36,7 @@ toolPolicies: "File System": never "Gates": never "HTML": never + "Hindsight": never "Images": never "Inbox": never "MCP": never diff --git a/scripts/build-market-packs.mjs b/scripts/build-market-packs.mjs index 4bbdaa3c8..41066bfc5 100644 --- a/scripts/build-market-packs.mjs +++ b/scripts/build-market-packs.mjs @@ -119,11 +119,20 @@ const PACKS = [ // routes), each authored in TS and bundled to lib/*.mjs with the REST client // inlined (provider.ts / routes.ts dynamic-import ./hindsight-client.js, which // esbuild inlines into each single self-contained file). platform:"node" so - // Buffer + node:* stay node globals/builtins. No CLIENT contributions yet - // (panel + tools land in G2.3). + // Buffer + node:* stay node globals/builtins. { in: "hindsight-client.ts", out: "lib/hindsight-client.mjs", platform: "node" }, { in: "provider.ts", out: "lib/provider.mjs", platform: "node" }, { in: "routes.ts", out: "lib/routes.mjs", platform: "node" }, + // CLIENT panel (browser): the native config/status surface (P4). `lit` stays + // external (host-injected); the bundle is a single self-contained ESM emitted + // to the shared lib/ dir, auto-discovered via panels/hindsight-memory.yaml. + // Retained as a NON-entry compatibility panel — the session-menu/route entries + // now target the embedded dashboard below. + { in: "panel.js", out: "lib/HindsightPanel.js" }, + // CLIENT panel (browser): the EMBEDDED DASHBOARD use surface. Opened by the + // session-menu entry + #/ext/hindsight; renders the configured human `uiUrl` + // in a sandboxed iframe. Auto-discovered via panels/hindsight-dashboard.yaml. + { in: "dashboard-panel.js", out: "lib/HindsightDashboardPanel.js" }, ], }, ]; diff --git a/src/app/api.ts b/src/app/api.ts index 47e6b390e..4a3fc96e6 100644 --- a/src/app/api.ts +++ b/src/app/api.ts @@ -2954,6 +2954,14 @@ export interface BrowsePackWire extends PackManifest { export interface InstalledPackWire { scope: MarketScope; packName: string; + /** Structural pack id (the `market-packs/` segment) the extension-host + * APIs key packs/runtimes by — NOT the manifest `name`. Equals `packName` for + * installed packs (installed into `market-packs//`) but can DIVERGE for + * built-in packs whose shipped directory differs from their manifest name. Use + * this (not `packName`) when addressing `/api/pack-runtimes/:id/*`. Optional so + * the client still works against an older server that omits it (falls back to + * `packName`). MUST stay in sync with the server `InstalledPackWire`. */ + packId?: string; manifest: PackManifest; meta: PackMeta; status: "ok" | "corrupt"; @@ -3083,15 +3091,24 @@ export function getPackConflicts(projectId?: string): Promise; + /** Schema-v2 contribution basenames (loader-resolved listNames). */ + providers?: string[]; + hooks?: string[]; + mcp?: string[]; + piExtensions?: string[]; + /** Managed-runtime descriptor basenames (Docker-backed; consent-gated). */ + runtimes?: string[]; + workflows?: string[]; /** One-line per-entity descriptions for the activation disclosure (R3). */ descriptions?: PackEntityDescriptions; } @@ -3124,3 +3149,195 @@ export function getPackActivation(scope: MarketScope, packName: string, projectI export function setPackActivation(opts: { scope: MarketScope; projectId?: string; packName: string; disabled: DisabledRefs }): Promise> { return marketFetch("/api/marketplace/pack-activation", jsonInit("PUT", opts)); } + +// ============================================================================ +// PACK MANAGED RUNTIMES (P3 — modes/consent design §8) +// +// Docker-backed runtimes a pack ships are consent-gated: the enable card must +// disclose images/services, host ports, the data/volume path and the +// memory/trust copy BEFORE the runtime starts. `startPolicy: on-enable` runtimes +// start ONLY from the explicit pack-activation enable action — never on +// boot/install/update. The capability summary is derived from the validated +// manifest + selected mode (no Docker required), so the disclosure renders even +// when Docker is unavailable or the runtime is stopped. External mode is a +// non-Docker setup path and reports `dockerRequired: false`. +// ============================================================================ + +/** One exposed host port for the enable-card disclosure. Mirrors the server's + * `PackRuntimeCapabilityPort` shape (pack-runtime-supervisor.ts) — there is no + * human `label`; the env var name / manifest key serve as the label. */ +export interface PackRuntimePortInfo { + /** Manifest persistence/env key (e.g. `HINDSIGHT_API_PORT`). */ + key: string; + /** Env var name the chosen host port is exposed under, when declared. */ + env?: string; + /** Informational container-side port the service listens on. */ + container?: number; + /** Allocated/persisted host port when one is already known (loopback-bound). */ + host?: number; +} + +/** Capability disclosure for a managed runtime, derived from the manifest + + * selected mode (design §8). Used to render the consent enable-card. */ +export interface PackRuntimeCapabilitySummary { + packId: string; + runtimeId: string; + /** Resolved deployment mode the summary describes (e.g. `managed-postgres`). */ + mode?: string; + /** Service/image names that will run for the selected mode (post-omit). */ + services: string[]; + /** Exposed host ports (loopback) the runtime will bind. */ + ports: PackRuntimePortInfo[]; + /** Effective host data/volume path for managed modes (e.g. `~/.hindsight`). */ + volumePath?: string; + /** True for Docker-managed modes; false for the external (no-Docker) path. */ + dockerRequired?: boolean; + /** First-party memory/trust disclosure copy. */ + trust?: string; +} + +/** Build the URL-safe runtime API id (`:`) the + * `/api/pack-runtimes/:id/*` routes expect (mirrors the server's + * encodePackRuntimeId). `packId` is the pack's structural id (== packName for + * first-party built-ins). */ +export function encodeRuntimeApiId(packId: string, runtimeId: string): string { + return `${encodeURIComponent(packId)}:${encodeURIComponent(runtimeId)}`; +} + +/** GET capability disclosure for the consent enable-card. Best-effort: returns a + * MarketResult so the UI degrades gracefully (static copy) when the route is + * unavailable. `mode` selects the deployment mode to summarise. */ +export function getPackRuntimeCapabilities(opts: { packId: string; runtimeId: string; projectId?: string; mode?: string }): Promise> { + const params = new URLSearchParams(); + if (opts.projectId) params.set("projectId", opts.projectId); + if (opts.mode) params.set("mode", opts.mode); + const qs = params.toString(); + return marketFetch(`/api/pack-runtimes/${encodeRuntimeApiId(opts.packId, opts.runtimeId)}/capabilities${qs ? `?${qs}` : ""}`); +} + +/** POST `docker compose down` for a managed runtime. `volumes`+`removeState` + * effect an explicit PURGE (down -v + runtime-state removal); without them this + * is the uninstall-grade down that preserves bind-mounted data. */ +export function downPackRuntime(opts: { packId: string; runtimeId: string; projectId?: string; volumes?: boolean; removeState?: boolean }): Promise> { + // `projectId` goes on the query string (the server's down route reads it from + // the query, not the JSON body); only `volumes`/`removeState` ride the body. + const params = new URLSearchParams(); + if (opts.projectId) params.set("projectId", opts.projectId); + const qs = params.toString(); + const body: Record = {}; + if (opts.volumes) body.volumes = true; + if (opts.removeState) body.removeState = true; + return marketFetch(`/api/pack-runtimes/${encodeRuntimeApiId(opts.packId, opts.runtimeId)}/down${qs ? `?${qs}` : ""}`, jsonInit("POST", body)); +} + +/** POST explicit purge for a pack runtime: `down -v` + runtime-state removal + * (removes Docker volumes and supervisor-owned state). Destructive. */ +export function purgePackRuntime(opts: { scope: MarketScope; packName: string; runtimeId: string; projectId?: string }): Promise> { + return marketFetch("/api/marketplace/purge-runtime", jsonInit("POST", opts)); +} + +// ── Live runtime status + explicit start/stop (Hindsight UX polish, design §5.1) ── +// These mirror the server `PackRuntimeStatus` shape served by `GET /api/pack-runtimes` +// (runtimes/pack-runtime-supervisor.ts) — do NOT invent fields. Reading status is a +// PURE read (the supervisor `list`/`status` never starts Docker); the ONLY Docker +// start path is the explicit {@link startPackRuntime} call wired to a user click. + +/** One compose service's reported state in a {@link PackRuntimeStatus}. Mirrors the + * server `PackRuntimeServiceStatus`. */ +export interface PackRuntimeServiceStatus { + name: string; + state?: string; + health?: string; +} + +/** Live status of a managed pack runtime, mirroring the server `PackRuntimeStatus` + * (runtimes/pack-runtime-supervisor.ts). `status` is the supervisor's literal state; + * `docker-unavailable` means Docker is not installed/running (the runtime is + * effectively stopped). `id` is the URL-safe `encodePackRuntimeId(packId,runtimeId)`. */ +export interface PackRuntimeStatus { + id: string; + packId: string; + packName?: string; + runtimeId: string; + title?: string; + description?: string; + status: "docker-unavailable" | "stopped" | "starting" | "running" | "unhealthy"; + mode?: string; + composeProject?: string; + services?: PackRuntimeServiceStatus[]; + message?: string; +} + +/** GET the live status of every managed pack runtime. PURE read — never starts + * Docker (the supervisor `list` only inspects). Best-effort MarketResult so the + * marketplace degrades gracefully (no status strip) when the supervisor is + * unavailable (503) or Docker is absent. */ +export function listPackRuntimes(projectId?: string): Promise> { + const qs = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + return marketFetch(`/api/pack-runtimes${qs}`); +} + +/** POST an EXPLICIT start for a managed pack runtime (`/api/pack-runtimes/:id/start`). + * This is the ONLY Docker-starting path from the marketplace — it must be wired to a + * user click behind the consent disclosure, never to a status/capability read or page + * load. An external (non-managed) deployment answers 409 (no Docker to start) → + * surfaced as `ok:false`. `mode` overrides the deployment-derived runtime mode. */ +export function startPackRuntime(opts: { packId: string; runtimeId: string; projectId?: string; mode?: string }): Promise> { + const params = new URLSearchParams(); + if (opts.projectId) params.set("projectId", opts.projectId); + const qs = params.toString(); + const body: Record = {}; + if (opts.mode) body.mode = opts.mode; + return marketFetch(`/api/pack-runtimes/${encodeRuntimeApiId(opts.packId, opts.runtimeId)}/start${qs ? `?${qs}` : ""}`, jsonInit("POST", body)); +} + +/** POST stop for a managed pack runtime (`/api/pack-runtimes/:id/stop`). Brings the + * Docker containers down (preserving data); never destructive. */ +export function stopPackRuntime(opts: { packId: string; runtimeId: string; projectId?: string }): Promise> { + const params = new URLSearchParams(); + if (opts.projectId) params.set("projectId", opts.projectId); + const qs = params.toString(); + return marketFetch(`/api/pack-runtimes/${encodeRuntimeApiId(opts.packId, opts.runtimeId)}/stop${qs ? `?${qs}` : ""}`, jsonInit("POST", {})); +} + +/** GET recent logs for a managed pack runtime (`/api/pack-runtimes/:id/logs`). PURE + * read — never starts Docker. `tail` bounds the line count. A missing-Docker install + * answers 200 with `{ logs:"", status:"docker-unavailable" }`. */ +export function getPackRuntimeLogs(opts: { packId: string; runtimeId: string; projectId?: string; tail?: number }): Promise> { + const params = new URLSearchParams(); + if (opts.projectId) params.set("projectId", opts.projectId); + if (typeof opts.tail === "number") params.set("tail", String(opts.tail)); + const qs = params.toString(); + return marketFetch(`/api/pack-runtimes/${encodeRuntimeApiId(opts.packId, opts.runtimeId)}/logs${qs ? `?${qs}` : ""}`); +} + +/** GET a BUILT-IN pack's read-only route output as a PURE, SESSIONLESS read + * (`GET /api/ext/pack-route/:packId/:routeName`). The Marketplace uses this to read + * built-in Hindsight `status`/`config` after `#/market` navigation has cleared the + * active chat session — the surface-token path (`getLauncherHost` → `host.callRoute`) + * would 403 there because minting a surface token requires an active session. + * Admin-bearer + GET-only + built-in-pack-only on the server; it NEVER mutates and + * NEVER starts Docker (status/capability reads only; the only Docker start path stays + * the explicit {@link startPackRuntime} click). `packId` is the pack's STRUCTURAL id + * (use the installed row's `packId`, which equals `packName` for first-party packs). */ +export function readBuiltinPackRoute(opts: { packId: string; routeName: string; projectId?: string }): Promise> { + const qs = opts.projectId ? `?projectId=${encodeURIComponent(opts.projectId)}` : ""; + return marketFetch(`/api/ext/pack-route/${encodeURIComponent(opts.packId)}/${encodeURIComponent(opts.routeName)}${qs}`); +} + +/** POST a BUILT-IN pack's `config` route as a PURE, SESSIONLESS write + * (`POST /api/ext/pack-route/:packId/config`), mirroring {@link readBuiltinPackRoute} + * but carrying a JSON body. The Marketplace uses this to SAVE built-in Hindsight + * config inline after `#/market` navigation has cleared the active chat session (the + * surface-token path would 403 there). Admin-bearer + POST + built-in-pack-only on + * the server, ALLOWLISTED to the `config` route name only; it persists config to the + * pack store and NEVER starts Docker. The `config` route validates the body and + * returns the redacted effective config (or a `CONFIG_INVALID` structured error). + * `routeName` is "config" (the only writable route). */ +export function writeBuiltinPackRoute(opts: { packId: string; routeName: string; body: unknown; projectId?: string }): Promise> { + const qs = opts.projectId ? `?projectId=${encodeURIComponent(opts.projectId)}` : ""; + return marketFetch( + `/api/ext/pack-route/${encodeURIComponent(opts.packId)}/${encodeURIComponent(opts.routeName)}${qs}`, + jsonInit("POST", opts.body), + ); +} diff --git a/src/app/main.ts b/src/app/main.ts index d68465456..1eb098976 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -228,7 +228,12 @@ async function evaluateActiveExtRoute(): Promise { if (cur.params) for (const key of entry.paramKeys) if (key in cur.params) openParams[key] = cur.params[key]; // The target panel is resolved within the SAME pack — thread the route's // owning packId so the {packId, panelId} lookup is exact (pack schema V1 §8.1). - openPackPanel({ panelId: entry.targetPanelId, params: openParams }, entry.packId); + // Hindsight's #/ext/hindsight route is a use surface for an in-app dashboard + // tab, so thread a restored session when possible. Other extension routes + // (notably PR Walkthrough) intentionally remain on the #/ext route overlay. + const wantsSessionTab = entry.packId === "hindsight" && entry.targetPanelId === "hindsight.dashboard"; + const sessionId = wantsSessionTab ? state.selectedSessionId || state.remoteAgent?.gatewaySessionId || state.gatewaySessions[0]?.id || undefined : undefined; + openPackPanel({ panelId: entry.targetPanelId, params: openParams, ...(sessionId ? { sessionId } : {}) }, entry.packId); } catch { /* non-fatal — a bad deep-link must never break the app */ } } diff --git a/src/app/marketplace-page.ts b/src/app/marketplace-page.ts index 1b2e4b914..c33db92a5 100644 --- a/src/app/marketplace-page.ts +++ b/src/app/marketplace-page.ts @@ -11,16 +11,26 @@ import { html, TemplateResult } from "lit"; import { AlertTriangle, ArrowLeft, + CheckCircle2, ChevronDown, + ChevronRight, + Circle, Database, Download, + ExternalLink, GripVertical, Package, + Play, + Plug, Plus, RotateCw, + ScrollText, + Settings, ShoppingCart, + Square, Store, Trash2, + XCircle, } from "lucide"; import type { IconNode } from "lucide"; import { renderApp, state } from "./state.js"; @@ -41,6 +51,13 @@ import { updateInstalledPack, fetchContributions, fetchTools, + getPackRuntimeCapabilities, + getPackRuntimeLogs, + listPackRuntimes, + readBuiltinPackRoute, + writeBuiltinPackRoute, + startPackRuntime, + stopPackRuntime, type BrowsePackWire, type ConflictWire, type DisabledRefs, @@ -49,6 +66,8 @@ import { type MarketScope, type PackActivationResponse, type PackEntityDescriptions, + type PackRuntimeCapabilitySummary, + type PackRuntimeStatus, } from "./api.js"; // ============================================================================ @@ -80,6 +99,214 @@ let conflicts: ConflictWire[] = []; * visible + re-enableable. */ const activationByPack = new Map(); +/** Per-runtime capability disclosure for the consent enable-card (P3 design §8), + * keyed by {@link runtimeCapabilityCacheKey} (`${scope}:${structuralPackId}:${runtimeId}:${projectId}`). + * The key carries the projectId so switching project focus refetches rather than + * reusing a stale summary, and the STRUCTURAL pack id so it matches what the + * fetch addressed. Derived from the validated + * manifest + selected mode (no Docker needed), fetched lazily + best-effort so + * the disclosure paints even when Docker is unavailable / the runtime stopped. + * `null` = fetch attempted but unavailable (route not present / errored) → + * the card falls back to static disclosure copy. */ +const runtimeCapabilities = new Map(); +/** Guard so we issue at most one in-flight capability fetch per runtime key. */ +const runtimeCapabilitiesInFlight = new Set(); + +// ── Hindsight built-in row: live config/runtime state (design hindsight-ux-polish §5) ── +// Read-only derivation feeding the richer state badge + action bar on the built-in +// `hindsight` row. Fetching status / the runtime list is a PURE read — it NEVER starts +// Docker (the only start path is the explicit Start-runtime click). Invalidated + +// re-fetched alongside the other background loads in loadMarketplaceData. +const HINDSIGHT_PACK = "hindsight"; +const HINDSIGHT_RUNTIME = "hindsight"; +const HINDSIGHT_PANEL_ID = "hindsight.panel"; +const HINDSIGHT_DASHBOARD_ROUTE = "#/ext/hindsight"; + +/** Subset of the Hindsight `status` route response the marketplace needs. The + * `externalUrl`/`uiUrl`/`timeoutMs`/`recallBudget` fields are additive (Partition C) + * and optional, so this works whether or not that partition has merged. */ +interface HindsightStatusWire { + configured?: boolean; + mode?: string; + bank?: string; + namespace?: string; + recallScope?: string; + autoRecall?: boolean; + autoRetain?: boolean; + queueDepth?: number; + healthy?: boolean; + // The route persists the last error as a `{ message, ts }` diagnostic object + // (market-packs/hindsight/src/shared.ts), but legacy/string shapes are tolerated. + // Render via `hindsightLastErrorText` so an object never stringifies to + // `[object Object]`. + lastError?: string | { message?: string; ts?: number } | null; + externalUrl?: string; + uiUrl?: string; + timeoutMs?: number; + recallBudget?: number; +} + +/** The redacted `config` route GET response shape the inline form hydrates from. + * Secrets are surfaced as `Set` booleans, never raw values. `externalUrl`/ + * `uiUrl` are present only when set (resolveConfig omits empty strings). */ +interface HindsightConfigWire { + mode?: string; + externalUrl?: string; + uiUrl?: string; + bank?: string; + namespace?: string; + recallScope?: string; + autoRecall?: boolean; + autoRetain?: boolean; + timeoutMs?: number; + recallBudget?: number; + dataDir?: string; + apiKeySet?: boolean; +} + +/** The per-project override metadata the `config` GET route exposes when a + * `projectId` is supplied (design hindsight-memory-quality §"Per-project override"). + * Only the safe memory-quality keys are surfaced; absent fields inherit the global + * config. Optional everywhere — when the route partition that adds this hasn't + * merged, the response simply omits these and the override UI stays dormant. */ +interface HindsightProjectOverrideWire { + recallScope?: string; + bank?: string; + tagsMatch?: string; + recallBudget?: number; + recallTypes?: string[]; +} + +/** The full `config` GET response. `config` is the EFFECTIVE (overlay-resolved) + * config; `globalConfig`/`projectOverride` are present only when the route was + * asked for a specific project AND supports per-project overlays. */ +interface HindsightConfigGetWire { + config?: HindsightConfigWire; + globalConfig?: HindsightConfigWire; + projectOverride?: HindsightProjectOverrideWire | null; + projectId?: string; +} + +/** Editable values for the inline Configure form (strings for input binding). */ +interface HindsightConfigFormValues { + mode: string; + externalUrl: string; + uiUrl: string; + bank: string; + namespace: string; + recallScope: string; + autoRecall: boolean; + autoRetain: boolean; + timeoutMs: string; + recallBudget: string; + apiKey: string; +} + +const HINDSIGHT_CONFIG_FIELD_KEYS: Array = [ + "mode", + "externalUrl", + "uiUrl", + "bank", + "namespace", + "recallScope", + "autoRecall", + "autoRetain", + "timeoutMs", + "recallBudget", + "apiKey", +]; + +let hindsightStatus: HindsightStatusWire | null = null; +let hindsightStatusLoaded = false; +let hindsightRuntimes: PackRuntimeStatus[] = []; +/** Transient result lozenge for the Test connection / Start / Stop actions. */ +let hindsightActionResult: { kind: "test" | "start" | "stop"; ok: boolean; message: string } | null = null; +/** Whether the explicit managed-Start consent disclosure is expanded (start stays a + * second explicit click inside it — never auto-fired). */ +let hindsightStartConsentOpen = false; +/** Inline runtime logs (View logs), fetched best-effort; null = not loaded. */ +let hindsightLogs: string | null = null; + +/** Whether the inline Configure form is expanded (Configure click toggles it). */ +let hindsightConfigFormOpen = false; +/** The editable form values, hydrated from the `config` route on open. */ +let hindsightConfigForm: HindsightConfigFormValues | null = null; +/** The values as loaded from the route (apiKey loaded blank) — the touched-field + * baseline so the save only POSTs fields the user actually changed (an untouched + * blank secret input never clobbers a stored secret). */ +let hindsightConfigLoaded: HindsightConfigFormValues | null = null; +/** Whether a stored apiKey exists (drives the "set/blank" hint on the secret field). */ +let hindsightConfigApiKeySet = false; +/** Transient save-result lozenge for the inline config form. */ +let hindsightConfigResult: { ok: boolean; message: string } | null = null; + +// ── Per-project memory override (design hindsight-memory-quality §"Per-project +// override"). The built-in Hindsight pack is server-scoped, but its memory config +// supports a small project overlay (recallScope + optional bank) layered over the +// global config. The marketplace passes the CURRENT projectId for the built-in row +// so the config route can surface + persist that overlay. All state degrades to a +// dormant section when the route partition exposing it hasn't merged. ── +/** The projectId the override section addresses (current project), if any. */ +let hindsightOverrideProjectId: string | undefined; +/** Whether the config route exposed the per-project overlay contract (globalConfig / + * projectOverride present). False ⇒ the override section is hidden entirely. */ +let hindsightOverrideSupported = false; +/** The server/global base config (used to label "inherit ()" affordances). */ +let hindsightGlobalConfig: HindsightConfigWire | null = null; +/** The stored project overlay (null/empty ⇒ no override; inherits global). */ +let hindsightProjectOverride: HindsightProjectOverrideWire | null = null; +/** Editable override form values. "" recallScope ⇒ inherit; "" bank ⇒ inherit. */ +let hindsightOverrideForm: { recallScope: string; bank: string } | null = null; +/** Transient save-result lozenge for the per-project override save. */ +let hindsightOverrideResult: { ok: boolean; message: string } | null = null; + +// ── Hindsight guided setup WIZARD (design extension-platform §11 + G3.3) ────── +// Clicking Enable on a DISABLED built-in Hindsight row launches a guided wizard +// (mode → defaults+rationale → test/start with progress → smoke test → finish) +// INSTEAD of flipping the pack enabled immediately. Finish saves config via the +// sessionless config-write seam, THEN enables the pack. Cancel before the +// connect/start action persists nothing and leaves the pack disabled. The wizard +// reuses the existing config-write, status-read, runtime-start and consent +// disclosure paths — it is a guided SEQUENCING on top of them, not a rewrite. +export type HindsightWizardStep = "mode" | "configure" | "connect" | "smoke"; +export type HindsightWizardMode = "external" | "managed" | "managed-external-postgres"; + +/** Editable wizard form. Distinct from the inline-Configure form values so the + * wizard can surface the managed-mode fields (llmApiKey/dataDir/externalDatabaseUrl) + * and the recall-query clamp (recallMaxInputChars) with their own rationale copy. */ +interface HindsightWizardForm { + mode: HindsightWizardMode; + externalUrl: string; + uiUrl: string; + apiKey: string; + llmApiKey: string; + externalDatabaseUrl: string; + dataDir: string; + bank: string; + namespace: string; + recallScope: string; + autoRecall: boolean; + autoRetain: boolean; + timeoutMs: string; + recallMaxInputChars: string; +} + +let hindsightWizardOpen = false; +/** `${scope}:${packName}` of the pack the wizard targets (so only that row renders it). */ +let hindsightWizardPackKey: string | null = null; +let hindsightWizardStep: HindsightWizardStep = "mode"; +let hindsightWizardForm: HindsightWizardForm | null = null; +/** Managed-mode consent (must be ticked before the explicit Start). */ +let hindsightWizardConsent = false; +/** Whether config has been persisted yet (set by the connect/start action + Finish). */ +let hindsightWizardConfigSaved = false; +/** Result of the connect/start action (External: Test; Managed: Start). */ +let hindsightWizardConnect: { ok: boolean; message: string } | null = null; +/** Best-effort smoke-test result (non-fatal). */ +let hindsightWizardSmoke: { ok: boolean; message: string } | null = null; +/** Surfaced wizard error (config validation / save failure). */ +let hindsightWizardError = ""; + let newSourceUrl = ""; let newSourceRef = ""; let addingSource = false; @@ -122,6 +349,32 @@ export function clearMarketplaceState(): void { installedError = ""; conflicts = []; activationByPack.clear(); + hindsightStatus = null; + hindsightStatusLoaded = false; + hindsightRuntimes = []; + hindsightActionResult = null; + hindsightStartConsentOpen = false; + hindsightLogs = null; + hindsightConfigFormOpen = false; + hindsightConfigForm = null; + hindsightConfigLoaded = null; + hindsightConfigApiKeySet = false; + hindsightConfigResult = null; + hindsightOverrideProjectId = undefined; + hindsightOverrideSupported = false; + hindsightGlobalConfig = null; + hindsightProjectOverride = null; + hindsightOverrideForm = null; + hindsightOverrideResult = null; + hindsightWizardOpen = false; + hindsightWizardPackKey = null; + hindsightWizardStep = "mode"; + hindsightWizardForm = null; + hindsightWizardConsent = false; + hindsightWizardConfigSaved = false; + hindsightWizardConnect = null; + hindsightWizardSmoke = null; + hindsightWizardError = ""; newSourceUrl = ""; newSourceRef = ""; addingSource = false; @@ -202,6 +455,12 @@ export async function loadMarketplaceData(showLoading = true): Promise { loading = true; renderApp(); } + // Drop cached runtime capability disclosures so the consent enable-card refetches + // against the server's CURRENT deployment config. The user may have changed the + // Hindsight deployment mode (e.g. external → managed) in the panel since this view + // was last open; without this the stale disclosure would be shown right before the + // enable toggle (see invalidateRuntimeCapabilities). + invalidateRuntimeCapabilities(); const projectId = currentProjectId(); const [srcRes, instRes, confRes] = await Promise.all([ @@ -241,10 +500,82 @@ export async function loadMarketplaceData(showLoading = true): Promise { // Activation catalogues are fetched in the background (one GET per installed // pack) so the page paints immediately; the toggles appear once they resolve. void loadActivationForInstalled(); + // Hindsight built-in row state (config + runtime) — background, best-effort, and a + // PURE read (never starts Docker). Reset transient action UI on a fresh load. + hindsightActionResult = null; + hindsightStartConsentOpen = false; + hindsightLogs = null; + void loadHindsightState(); if (selectedSourceId) await loadBrowse(selectedSourceId); } +/** Best-effort load of the built-in Hindsight row's live state: the runtime list + * (`GET /api/pack-runtimes`, admin-bearer) plus the pack `status` route. BOTH are + * pure reads — neither starts Docker. The `status` read goes through the SESSIONLESS + * built-in pack-route seam ({@link readBuiltinPackRoute}) rather than the launcher + * Host API: after `#/market` navigation there is no active chat session, so the + * surface-token mint the Host API needs would 403 and the row would stay stuck on + * "Unknown" (the production bug this fix targets). Only runs when the built-in + * `hindsight` pack is installed; silently degrades (badge shows "Checking…"/"Unknown") + * when a read fails. */ +async function loadHindsightState(): Promise { + const pack = installed.find((p) => p.builtin && p.packName === HINDSIGHT_PACK); + if (!pack) return; + const projectId = currentProjectId(); + const runtimesRes = await listPackRuntimes(projectId); + if (runtimesRes.ok) hindsightRuntimes = runtimesRes.data.runtimes || []; + // Pass the CURRENT projectId for the built-in row too (not just project-scope + // packs): the config route overlays the per-project memory override + reports the + // EFFECTIVE recall scope for it. A route partition that ignores projectId simply + // returns the global view (override section stays dormant). + const statusRes = await readBuiltinPackRoute({ + packId: runtimeRestPackId(pack), + routeName: "status", + projectId, + }); + if (statusRes.ok) { + hindsightStatus = statusRes.data ?? null; + hindsightStatusLoaded = true; + } + // Read config (project-scoped) to surface the per-project override badge on the + // summary WITHOUT opening Configure. Pure read; populates META only (never the + // editable override form, which Configure seeds on open). + const cfgRes = await readBuiltinPackRoute({ + packId: runtimeRestPackId(pack), + routeName: "config", + projectId, + }); + if (cfgRes.ok && cfgRes.data) applyHindsightOverrideMeta(cfgRes.data, projectId); + renderApp(); +} + +/** Populate the per-project override METADATA (support flag, global base, stored + * overlay) from a `config` GET response. Does NOT touch the editable override form. + * The route exposes the overlay contract only when it returns `globalConfig` or a + * `projectOverride` field; absent ⇒ the section stays hidden. */ +function applyHindsightOverrideMeta(data: HindsightConfigGetWire, projectId: string | undefined): void { + hindsightOverrideProjectId = projectId; + const supported = !!projectId && (Object.prototype.hasOwnProperty.call(data, "globalConfig") || Object.prototype.hasOwnProperty.call(data, "projectOverride")); + hindsightOverrideSupported = supported; + hindsightGlobalConfig = data.globalConfig ?? null; + hindsightProjectOverride = data.projectOverride ?? null; +} + +/** True when a non-empty per-project overlay is stored (drives the summary badge). */ +function hasHindsightProjectOverride(): boolean { + const o = hindsightProjectOverride; + if (!o) return false; + return !!(o.recallScope || (o.bank && o.bank.trim()) || o.tagsMatch || o.recallBudget != null || (o.recallTypes && o.recallTypes.length)); +} + +/** Resolve the current project's display name for override copy. */ +function hindsightProjectName(): string { + const pid = hindsightOverrideProjectId; + if (!pid) return "this project"; + return state.projects.find((p) => p.id === pid)?.name || "this project"; +} + /** Fetch the UNFILTERED activation catalogue + disabled overrides for every * installed pack (pack schema V1 §6.7/§9). The catalogue is the SINGLE source * for the toggle UI — never the runtime-filtered /api/tools or @@ -264,14 +595,93 @@ async function loadActivationForInstalled(): Promise { if (changed) renderApp(); } +/** Toggleable entity kinds (singular testid form). The schema-v2 kinds + * (`provider`/`hook`/`mcp`/`pi-extension`/`runtime`/`workflow`) appear only for + * schema≥2 packs; `runtime` is the consent-gated managed Docker runtime. */ +type ActivationKind = "role" | "tool" | "skill" | "entrypoint" | "provider" | "hook" | "mcp" | "pi-extension" | "runtime" | "workflow"; + /** Maps the singular testid kind → the `DisabledRefs` array key. */ -const ACTIVATION_KIND_KEY: Record<"role" | "tool" | "skill" | "entrypoint", keyof DisabledRefs> = { +const ACTIVATION_KIND_KEY: Record = { role: "roles", tool: "tools", skill: "skills", entrypoint: "entrypoints", + provider: "providers", + hook: "hooks", + mcp: "mcp", + "pi-extension": "piExtensions", + runtime: "runtimes", + workflow: "workflows", }; +/** Memory/trust disclosure shown on the managed-runtime consent enable-card + * (design §8). Enabling starts Docker containers that store + recall agent + * memory; disabling stops them but keeps data; purge removes the volumes. */ +const RUNTIME_MEMORY_DISCLOSURE = + "Enabling this managed runtime starts local Docker containers that store and recall agent memory — conversation summaries plus project/goal/session tags — in the configured memory bank. Disabling stops the containers but keeps your data on disk; purging removes the Docker volumes and runtime state."; + +/** External-mode setup guidance (no Docker). Shown when the runtime is configured + * to talk to an already-running Hindsight instead of a Bobbit-managed one. */ +const RUNTIME_EXTERNAL_GUIDANCE = + "External mode does not run Docker. Point Bobbit at an existing Hindsight deployment by configuring its URL, optional API key, namespace and memory bank in the provider settings."; + +/** Structural pack id used to address the runtime REST routes + * (`/api/pack-runtimes/:id/*`). The extension-host keys packs/runtimes by the + * `market-packs/` STRUCTURAL id, which can diverge from the manifest + * `name` for a built-in pack — so passing `packName` would 404 the capability + * lookup. Prefer the wire's `packId`; fall back to `packName` only for an older + * server that omits it (where the two coincide for installed packs). */ +export function runtimeRestPackId(pack: { packId?: string; packName: string }): string { + return pack.packId ?? pack.packName; +} + +/** Cache / in-flight key for a runtime capability fetch. MUST include the + * projectId the fetch is scoped to: project-scope packs fetch with + * {@link currentProjectId}, so omitting it would reuse one project's disclosure + * after the user switches project focus (stale capability summary). Server-scope + * packs always fetch with no projectId, so their key carries an empty segment. */ +export function runtimeCapabilityCacheKey( + scope: MarketScope, + packId: string, + runtimeId: string, + projectId: string | undefined, +): string { + return `${scope}:${packId}:${runtimeId}:${projectId ?? ""}`; +} + +/** Drop every cached runtime capability disclosure (and any in-flight guard) so + * the consent enable-card refetches fresh from the server. The disclosure is a + * function of the SERVER's current deployment config (mode/dataDir/…), which the + * user changes elsewhere (the Hindsight panel writes the provider config). The + * cache key cannot encode that revision, so a stale `external` disclosure would + * otherwise survive a switch to `managed` and be shown immediately before the + * enable. Called whenever the marketplace view (re)loads, so the consent text + * always matches current server config before the user toggles enable. */ +export function invalidateRuntimeCapabilities(): void { + runtimeCapabilities.clear(); + runtimeCapabilitiesInFlight.clear(); +} + +/** Lazily fetch + cache the capability disclosure for a managed runtime so the + * consent enable-card can render images/services, ports, volume path and trust + * copy. Best-effort: a missing route / error caches `null` and the card falls + * back to static copy. Repaints once resolved. Exported for the staleness + * regression test (drives the fetch/cache via a stubbed window.fetch). */ +export function ensureRuntimeCapabilities(pack: InstalledPackWire, runtimeId: string): void { + const projectId = pack.scope === "project" ? currentProjectId() : undefined; + const restPackId = runtimeRestPackId(pack); + // Cache key tracks the STRUCTURAL pack id + the projectId the fetch is scoped + // to, so a project-focus switch refetches rather than reusing a stale summary. + const key = runtimeCapabilityCacheKey(pack.scope, restPackId, runtimeId, projectId); + if (runtimeCapabilities.has(key) || runtimeCapabilitiesInFlight.has(key)) return; + runtimeCapabilitiesInFlight.add(key); + void getPackRuntimeCapabilities({ packId: restPackId, runtimeId, projectId }).then((res) => { + runtimeCapabilitiesInFlight.delete(key); + runtimeCapabilities.set(key, res.ok ? res.data : null); + renderApp(); + }); +} + /** Toggle a user-facing pack entity's activation. Computes the new `disabled` * set, PUTs it (the response carries the refreshed catalogue + normalized * disabled — no follow-up GET), then re-runs the marketplace reconcile so a @@ -279,7 +689,7 @@ const ACTIVATION_KIND_KEY: Record<"role" | "tool" | "skill" | "entrypoint", keyo * (pack schema V1 §9). Entrypoints are keyed by `listName`. */ async function handleToggleActivation( pack: InstalledPackWire, - kind: "role" | "tool" | "skill" | "entrypoint", + kind: ActivationKind, name: string, enable: boolean, ): Promise { @@ -297,13 +707,23 @@ async function handleToggleAllActivation(pack: InstalledPackWire, enable: boolea const current = activationByPack.get(cacheKey); if (!current) return; const cat = current.catalogue; + // Disabling-all MUST cover the schema-v2 arrays too (providers/hooks/mcp/ + // piExtensions/runtimes/workflows) — otherwise the master OFF toggle would + // leave a managed runtime enabled (and Docker running). Enabling-all clears + // every kind back to the default-enabled state. const disabled: DisabledRefs = enable - ? { roles: [], tools: [], skills: [], entrypoints: [] } + ? { roles: [], tools: [], skills: [], entrypoints: [], providers: [], hooks: [], mcp: [], piExtensions: [], runtimes: [], workflows: [] } : { roles: [...cat.roles], tools: [...cat.tools], skills: [...cat.skills], entrypoints: cat.entrypoints.map((e) => e.listName), + providers: [...(cat.providers ?? [])], + hooks: [...(cat.hooks ?? [])], + mcp: [...(cat.mcp ?? [])], + piExtensions: [...(cat.piExtensions ?? [])], + runtimes: [...(cat.runtimes ?? [])], + workflows: [...(cat.workflows ?? [])], }; await savePackActivation(pack, disabled, `activation:${cacheKey}:all`); } @@ -997,7 +1417,9 @@ function renderBuiltinPackCard(pack: InstalledPackWire): TemplateResult { ${shadowed ? html`
    Shadowed by an installed pack — manage activation on the installed copy.
    ` - : html`${renderActivationControls(pack)}${renderActivationEntityDetails(pack)}`} + : isHindsightWizardOpenFor(pack) + ? renderHindsightWizard(pack) + : html`${pack.packName === HINDSIGHT_PACK ? renderHindsightStatusStrip(pack) : ""}${renderActivationControls(pack)}${renderActivationEntityDetails(pack)}`} `; } @@ -1107,7 +1529,7 @@ function renderPackActivationSummary(pack: InstalledPackWire): TemplateResult { data-testid="market-toggle-pack-${pack.packName}" .checked=${enabled > 0} ?disabled=${busy.has(busyKey)} - @change=${(e: Event) => handleToggleAllActivation(pack, (e.target as HTMLInputElement).checked)} + @change=${(e: Event) => handleMasterToggle(pack, e.target as HTMLInputElement)} /> @@ -1115,18 +1537,60 @@ function renderPackActivationSummary(pack: InstalledPackWire): TemplateResult { `; } -function activationEntityTotal(activation: PackActivationResponse): number { - const cat = activation.catalogue; - return cat.roles.length + cat.tools.length + cat.skills.length + cat.entrypoints.length; +/** Master enable/disable toggle handler. For a DISABLED built-in Hindsight row, + * turning the pack ON launches the guided setup wizard INSTEAD of flipping the + * activation — the pack only becomes enabled when the wizard reaches Finish. All + * other packs (and disabling Hindsight) toggle activation directly. */ +function handleMasterToggle(pack: InstalledPackWire, el: HTMLInputElement): void { + const checked = el.checked; + if (checked && shouldLaunchHindsightWizard(pack)) { + // Intercept: the pack stays disabled (the bound `.checked` value is unchanged), + // so lit will NOT reset the user-toggled checkbox — reset the DOM element directly + // so the toggle does not appear "on" while the wizard (and after Cancel) is shown. + el.checked = false; + openHindsightWizard(pack); + return; + } + void handleToggleAllActivation(pack, checked); +} + +/** Whether clicking Enable on this row should launch the guided wizard rather than + * enabling immediately: a built-in `hindsight` row that is currently disabled. + * Consumes the sibling server `requiresGuidedSetup` field ADDITIVELY — only an + * explicit `false` opts out (absent/true ⇒ launch), so it works whether or not the + * default-disabled server change has merged. */ +export function shouldLaunchHindsightWizard(pack: InstalledPackWire): boolean { + if (!(pack.builtin && pack.packName === HINDSIGHT_PACK)) return false; + if (hindsightEnabled(pack)) return false; + const requiresGuided = (pack as { requiresGuidedSetup?: boolean }).requiresGuidedSetup; + if (requiresGuided === false) return false; + return true; +} + +/** Every keyof DisabledRefs that the catalogue counts as a toggleable entity. + * Keeps the master-toggle total/enabled count in sync with the schema-v2 + * arrays (so a managed runtime is part of "Enabled"/"Disabled"). */ +const ACTIVATION_COUNT_KINDS: Array = [ + "roles", "tools", "skills", "entrypoints", + "providers", "hooks", "mcp", "piExtensions", "runtimes", "workflows", +]; + +export function activationEntityTotal(activation: PackActivationResponse): number { + const cat = activation.catalogue as Record; + let total = 0; + for (const kind of ACTIVATION_COUNT_KINDS) { + const arr = cat[kind]; + if (Array.isArray(arr)) total += arr.length; + } + return total; } -function activationEntityEnabledCount(activation: PackActivationResponse): number { +export function activationEntityEnabledCount(activation: PackActivationResponse): number { const disabled = activation.disabled || {}; - const disabledCount = - (disabled.roles ?? []).length + - (disabled.tools ?? []).length + - (disabled.skills ?? []).length + - (disabled.entrypoints ?? []).length; + let disabledCount = 0; + for (const kind of ACTIVATION_COUNT_KINDS) { + disabledCount += (disabled[kind] ?? []).length; + } return Math.max(0, activationEntityTotal(activation) - disabledCount); } @@ -1164,7 +1628,7 @@ function renderActivationControls(pack: InstalledPackWire): TemplateResult { const isEnabled = (kindKey: keyof DisabledRefs, name: string) => !(disabled[kindKey] ?? []).includes(name); const toggle = ( - kind: "role" | "tool" | "skill" | "entrypoint", + kind: ActivationKind, name: string, label: string, kindLabel?: string, @@ -1204,6 +1668,25 @@ function renderActivationControls(pack: InstalledPackWire): TemplateResult { if (cat.entrypoints.length) { groups.push(group("Entry points", cat.entrypoints.map((e) => toggle("entrypoint", e.listName, entrypointDisplayLabel(e), entrypointKindLabel(e.kind))))); } + // Schema-v2 toggleable arrays (present only for schema≥2 packs). + if (cat.providers?.length) groups.push(group("Providers", cat.providers.map((n) => toggle("provider", n, n)))); + if (cat.hooks?.length) groups.push(group("Hooks", cat.hooks.map((n) => toggle("hook", n, n)))); + if (cat.mcp?.length) groups.push(group("MCP servers", cat.mcp.map((n) => toggle("mcp", n, n)))); + if (cat.piExtensions?.length) groups.push(group("Extensions", cat.piExtensions.map((n) => toggle("pi-extension", n, n)))); + if (cat.workflows?.length) groups.push(group("Workflows", cat.workflows.map((n) => toggle("workflow", n, n)))); + // Managed runtimes get an explicit consent enable-card per runtime (design §8): + // the toggle is the explicit on-enable start action, so the disclosure (images/ + // services, ports, volume path, memory/trust copy) renders inline with it. + if (cat.runtimes?.length) { + groups.push(html` +
    +
    Runtimes
    +
    + ${cat.runtimes.map((runtimeId) => renderRuntimeRow(pack, runtimeId, isEnabled("runtimes", runtimeId)))} +
    +
    + `); + } if (groups.length === 0) return html``; return html` @@ -1213,6 +1696,1202 @@ function renderActivationControls(pack: InstalledPackWire): TemplateResult { `; } +/** A managed-runtime activation row: the explicit on-enable toggle plus the + * consent enable-card disclosing what starting it does (design §8). */ +function renderRuntimeRow(pack: InstalledPackWire, runtimeId: string, checked: boolean): TemplateResult { + ensureRuntimeCapabilities(pack, runtimeId); + const busyKey = `activation:${pack.scope}:${pack.packName}:runtime:${runtimeId}`; + return html` +
    + + ${renderRuntimeConsentCard(pack, runtimeId)} +
    + `; +} + +/** The consent enable-card for a managed runtime (looks up the cached capability + * summary, then defers to the pure {@link renderRuntimeConsentCardView}). + * Exported for the staleness regression test. */ +export function renderRuntimeConsentCard(pack: InstalledPackWire, runtimeId: string): TemplateResult { + const projectId = pack.scope === "project" ? currentProjectId() : undefined; + const key = runtimeCapabilityCacheKey(pack.scope, runtimeRestPackId(pack), runtimeId, projectId); + return renderRuntimeConsentCardView(runtimeId, runtimeCapabilities.get(key)); +} + +/** Pure view for the managed-runtime consent enable-card. Discloses images/ + * services, host ports, the data/volume path and the memory/trust copy BEFORE + * enabling (design §8). External (no-Docker) mode shows setup guidance instead. + * Renders from the capability summary when available, else static fallback copy. + * Exported for focused render tests (no module state / fetch). */ +export function renderRuntimeConsentCardView(runtimeId: string, cap: PackRuntimeCapabilitySummary | null | undefined): TemplateResult { + const external = cap?.dockerRequired === false; + const services = cap?.services ?? []; + const ports = cap?.ports ?? []; + const volumePath = cap?.volumePath; + const trust = cap?.trust || RUNTIME_MEMORY_DISCLOSURE; + + if (external) { + return html` +
    +
    ${icon(Database, "xs")} External mode — no Docker
    +

    ${RUNTIME_EXTERNAL_GUIDANCE}

    +
    + `; + } + + // The server only fills `host` once a stable loopback port is persisted; `container` + // is informational. Render a `127.0.0.1:` loopback URL ONLY for an allocated + // host port — otherwise disclose the host port is allocated on enable (showing the + // container port separately when known) so we never imply a loopback bind that does + // not exist yet. + const portText = ports.length + ? ports.map((p) => { + const label = p.env || p.key; + const prefix = label ? `${label}: ` : ""; + if (typeof p.host === "number") return `${prefix}127.0.0.1:${p.host}`; + if (typeof p.container === "number") return `${prefix}container :${p.container}, host port allocated on enable`; + return `${prefix}allocated on enable`; + }).join(", ") + : "loopback ports allocated on enable"; + const serviceText = services.length ? services.join(", ") : "api, db"; + + return html` +
    +
    ${icon(Database, "xs")} Enabling starts a local Docker runtime
    +
    +
    Services
    +
    ${serviceText}
    +
    Ports
    +
    ${portText}
    +
    Data
    +
    ${volumePath || "~/.hindsight"}
    +
    +

    ${trust}

    +
    + `; +} + +// ============================================================================ +// HINDSIGHT BUILT-IN ROW — derived state badge + state-aware action bar +// (design hindsight-ux-polish §5.2). Read-only derivation; the ONLY Docker-start +// path is the explicit Start-runtime click gated behind the consent disclosure. +// ============================================================================ + +/** The eight-state model shared with the panel badge (design D1). `unknown` covers + * the pre-load window (status not yet read / no active session to read it). */ +export type HindsightUiState = + | "disabled" + | "dormant" + | "external-connected" + | "external-unreachable" + | "managed-stopped" + | "managed-starting" + | "managed-running" + | "managed-unhealthy" + | "unknown"; + +/** Pure derivation feeding the marketplace badge (and mirrored by the panel). Exported + * for focused unit tests. Combines activation (Disabled), the pack `status` route + * (Dormant / External connected|unreachable) and the managed runtime supervisor status + * (Managed stopped|starting|running|unhealthy). NEVER starts Docker — pure projection. */ +export function deriveHindsightState(opts: { + enabled: boolean; + statusLoaded: boolean; + status: HindsightStatusWire | null; + runtime: PackRuntimeStatus | undefined; +}): HindsightUiState { + if (!opts.enabled) return "disabled"; + const s = opts.status; + if (!opts.statusLoaded || !s) return "unknown"; + if (!s.configured) return "dormant"; + const mode = s.mode || "external"; + const managed = mode === "managed" || mode === "managed-external-postgres"; + if (!managed) return s.healthy ? "external-connected" : "external-unreachable"; + // Managed: prefer the live supervisor status; fall back to the health probe. + const rs = opts.runtime?.status; + if (rs === "running") return s.healthy === false ? "managed-unhealthy" : "managed-running"; + if (rs === "starting") return "managed-starting"; + if (rs === "unhealthy") return "managed-unhealthy"; + if (rs === "stopped" || rs === "docker-unavailable") return "managed-stopped"; + return s.healthy ? "managed-running" : "managed-stopped"; +} + +/** Badge presentation for a derived state. Colour is NEVER the only signal — each + * state pairs a semantic theme token with a distinct icon + plain-language blurb. */ +function hindsightStateMeta(state: HindsightUiState): { label: string; token: string; icon: typeof Circle; blurb: string } { + switch (state) { + case "disabled": + return { label: "Disabled", token: "var(--muted-foreground)", icon: Circle, blurb: "Pack disabled — enable it to use memory." }; + case "dormant": + return { label: "Not configured", token: "var(--warning, var(--chart-4))", icon: AlertTriangle, blurb: "Dormant until you configure a Hindsight deployment." }; + case "external-connected": + return { label: "Connected (external)", token: "var(--positive)", icon: CheckCircle2, blurb: "Talking to an external Hindsight data plane." }; + case "external-unreachable": + return { label: "Unreachable (external)", token: "var(--negative, var(--destructive))", icon: XCircle, blurb: "Configured for external mode but the API did not respond." }; + case "managed-stopped": + return { label: "Stopped (managed)", token: "var(--muted-foreground)", icon: Square, blurb: "Managed runtime configured but not running — Start it to bring Docker up." }; + case "managed-starting": + return { label: "Starting (managed)", token: "var(--info)", icon: RotateCw, blurb: "Managed Docker runtime is starting up." }; + case "managed-running": + return { label: "Running (managed)", token: "var(--positive)", icon: CheckCircle2, blurb: "Managed Docker runtime is running and healthy." }; + case "managed-unhealthy": + return { label: "Unhealthy (managed)", token: "var(--negative, var(--destructive))", icon: XCircle, blurb: "Managed runtime is up but failing health checks." }; + default: + return { label: "Checking…", token: "var(--muted-foreground)", icon: RotateCw, blurb: "Reading the current Hindsight state." }; + } +} + +/** The managed runtime status row for the built-in Hindsight pack (if any). */ +function hindsightRuntime(): PackRuntimeStatus | undefined { + return hindsightRuntimes.find((r) => r.runtimeId === HINDSIGHT_RUNTIME && (r.packId === HINDSIGHT_PACK || r.packName === HINDSIGHT_PACK)); +} + +/** Whether the built-in Hindsight pack/runtime activation is on. */ +function hindsightEnabled(pack: InstalledPackWire): boolean { + const activation = activationByPack.get(`${pack.scope}:${pack.packName}`); + if (!activation) return true; // assume enabled until the catalogue resolves + return activationEntityEnabledCount(activation) > 0; +} + +/** The built-in Hindsight row's state badge + active-config summary + action bar. */ +function renderHindsightStatusStrip(pack: InstalledPackWire): TemplateResult { + const runtime = hindsightRuntime(); + const state = deriveHindsightState({ + enabled: hindsightEnabled(pack), + statusLoaded: hindsightStatusLoaded, + status: hindsightStatus, + runtime, + }); + const meta = hindsightStateMeta(state); + const s = hindsightStatus; + const managed = state.startsWith("managed-"); + const isManagedMode = s?.mode === "managed" || s?.mode === "managed-external-postgres"; + + return html` +
    +
    + ${icon(meta.icon, "xs", state === "managed-starting" || state === "unknown" ? "animate-spin" : "")} ${meta.label} + ${hindsightActionResult + ? html`${icon(hindsightActionResult.ok ? CheckCircle2 : XCircle, "xs")} ${hindsightActionResult.message}` + : ""} +
    +
    ${meta.blurb}
    + ${renderHindsightConfigSummary()} + ${renderHindsightActions(pack, state, managed, isManagedMode)} + ${hindsightConfigFormOpen ? renderHindsightConfigForm(pack) : ""} + ${hindsightStartConsentOpen && state === "managed-stopped" + ? html`
    + ${renderRuntimeConsentCard(pack, HINDSIGHT_RUNTIME)} +
    + + +
    +
    ` + : ""} + ${hindsightLogs !== null + ? html`
    + Runtime logs +
    ${hindsightLogs || "(no output)"}
    +
    ` + : ""} +
    + `; +} + +/** Active configured values surfaced prominently on the row (data-plane URL, + * namespace, bank, recall/retain, timeout, queue depth). Read-only projection of the + * `status` route; shown once status has loaded and the pack is configured. */ +function renderHindsightConfigSummary(): TemplateResult { + const s = hindsightStatus; + if (!s || !s.configured) return html``; + const rows: Array<[string, string]> = []; + if (s.mode) rows.push(["Mode", s.mode]); + if (s.externalUrl) rows.push(["API URL", s.externalUrl]); + if (s.bank) rows.push(["Bank", s.bank]); + if (s.namespace) rows.push(["Namespace", s.namespace]); + if (s.recallScope) rows.push(["Recall scope", s.recallScope === "project" ? "project (this project + shared/global)" : "all (every project)"]); + rows.push(["Auto recall", s.autoRecall ? "on" : "off"]); + rows.push(["Auto retain", s.autoRetain ? "on" : "off"]); + if (typeof s.timeoutMs === "number") rows.push(["Timeout", `${s.timeoutMs}ms`]); + if (typeof s.recallBudget === "number") rows.push(["Recall budget", String(s.recallBudget)]); + if (typeof s.queueDepth === "number") rows.push(["Queue depth", String(s.queueDepth)]); + if (rows.length === 0) return html``; + return html` +
    + ${rows.map(([k, v]) => html`
    ${k}
    ${v}
    `)} +
    + ${hasHindsightProjectOverride() + ? html`${icon(Settings, "xs")} Project override active` + : ""} + ${(() => { + const le = hindsightLastErrorText(s.lastError); + return le ? html`
    ${le}
    ` : ""; + })()} + `; +} + +/** Normalise the `status.lastError` field, which the route persists as a + * `{ message, ts }` diagnostic object, to a human string. Returns "" when there is + * no usable message so the row renders nothing rather than `[object Object]`. */ +function hindsightLastErrorText(le: HindsightStatusWire["lastError"]): string { + if (le == null) return ""; + if (typeof le === "string") return le.trim(); + if (typeof le === "object") { + const msg = (le as { message?: unknown }).message; + if (typeof msg === "string" && msg.trim()) return msg.trim(); + } + return ""; +} + +/** State-aware action bar. Every action is an explicit click; Start stays gated behind + * the consent disclosure and is never auto-invoked. */ +function renderHindsightActions(pack: InstalledPackWire, state: HindsightUiState, managed: boolean, isManagedMode: boolean): TemplateResult { + const s = hindsightStatus; + const configured = !!s?.configured; + const testing = busy.has("hindsight:test"); + const stopping = busy.has("hindsight:stop"); + const loadingLogs = busy.has("hindsight:logs"); + const canStop = state === "managed-running" || state === "managed-unhealthy" || state === "managed-starting"; + return html` +
    + + + ${icon(ExternalLink, "xs")} Open Hindsight UI + ${s?.uiUrl + ? html`${icon(ExternalLink, "xs")} Open externally` + : ""} + ${isManagedMode && state === "managed-stopped" + ? html`` + : ""} + ${managed && canStop + ? html`` + : ""} + ${isManagedMode + ? html`` + : ""} +
    + `; +} + +/** Open the native Hindsight config/status panel — the in-session setup path, still + * reachable from the command palette / git-widget panel entrypoints. The Marketplace + * Configure button uses the inline form ({@link toggleHindsightConfigForm}) instead, + * because `#/market` has no active chat session to mount a pack side-panel against. */ +function openHindsightPanel(): void { + void import("./pack-panels.js").then((m) => m.openPackPanel({ panelId: HINDSIGHT_PANEL_ID }, HINDSIGHT_PACK)); +} +void openHindsightPanel; // retained: the in-session panel path is unchanged. + + +/** Default the inline form values (used when the config read hasn't populated a field). */ +function defaultHindsightForm(): HindsightConfigFormValues { + return { + mode: "external", + externalUrl: "", + uiUrl: "", + bank: "bobbit", + namespace: "default", + recallScope: "project", + autoRecall: true, + autoRetain: true, + timeoutMs: "1500", + recallBudget: "1200", + apiKey: "", + }; +} + +/** Toggle the inline Configure form. Opening hydrates it from the `config` route over + * the SESSIONLESS built-in seam (pure read, no Docker). */ +function toggleHindsightConfigForm(pack: InstalledPackWire): void { + if (hindsightConfigFormOpen) { + hindsightConfigFormOpen = false; + renderApp(); + return; + } + hindsightConfigFormOpen = true; + hindsightConfigResult = null; + renderApp(); + void loadHindsightConfigForm(pack); +} + +/** Hydrate the inline form + the touched-field baseline from the `config` route GET. + * The apiKey input ALWAYS loads blank (the secret is never echoed); leaving it blank + * keeps the stored secret on save. */ +async function loadHindsightConfigForm(pack: InstalledPackWire): Promise { + // Always pass the current projectId for the built-in row so the route can return + // the EFFECTIVE config + the per-project override metadata (when supported). + const projectId = currentProjectId(); + const res = await readBuiltinPackRoute({ + packId: runtimeRestPackId(pack), + routeName: "config", + projectId, + }); + const base = defaultHindsightForm(); + if (res.ok && res.data) applyHindsightOverrideMeta(res.data, projectId); + // Seed the editable per-project override form from the stored overlay ("" = + // inherit global). Done here (Configure open) rather than in the background load. + const ov = hindsightProjectOverride; + hindsightOverrideForm = { recallScope: ov?.recallScope ?? "", bank: ov?.bank ?? "" }; + hindsightOverrideResult = null; + // Hydrate the GLOBAL inline Configure fields (Bank, Recall scope, …) from + // `globalConfig` when the route exposes it — NOT from `config`, which is the + // overlay-resolved EFFECTIVE config. If a per-project override is active, `config` + // reflects the override, so seeding the global form from it would show project + // values as global and risk silently writing them back as global. `config` is for + // the effective summary/status row only. Fall back to `config` when the route + // predates the overlay contract (no `globalConfig`). + const globalCfg = res.ok ? (res.data?.globalConfig ?? res.data?.config) : undefined; + if (res.ok && globalCfg) { + const c = globalCfg; + const form: HindsightConfigFormValues = { + mode: c.mode ?? base.mode, + externalUrl: c.externalUrl ?? "", + uiUrl: c.uiUrl ?? "", + bank: c.bank ?? base.bank, + namespace: c.namespace ?? base.namespace, + recallScope: c.recallScope ?? base.recallScope, + autoRecall: typeof c.autoRecall === "boolean" ? c.autoRecall : base.autoRecall, + autoRetain: typeof c.autoRetain === "boolean" ? c.autoRetain : base.autoRetain, + timeoutMs: typeof c.timeoutMs === "number" ? String(c.timeoutMs) : base.timeoutMs, + recallBudget: typeof c.recallBudget === "number" ? String(c.recallBudget) : base.recallBudget, + apiKey: "", + }; + hindsightConfigApiKeySet = !!c.apiKeySet; + hindsightConfigForm = form; + hindsightConfigLoaded = { ...form }; + } else { + hindsightConfigApiKeySet = false; + hindsightConfigForm = base; + hindsightConfigLoaded = { ...base }; + } + renderApp(); +} + +/** Mutate one inline-form field (string/boolean) and repaint. */ +function setHindsightFormField(key: K, value: HindsightConfigFormValues[K]): void { + if (!hindsightConfigForm) hindsightConfigForm = defaultHindsightForm(); + hindsightConfigForm = { ...hindsightConfigForm, [key]: value }; + hindsightConfigResult = null; + renderApp(); +} + +function hindsightConfigDirtyFields(): Set { + if (!hindsightConfigForm || !hindsightConfigLoaded) return new Set(); + return new Set(HINDSIGHT_CONFIG_FIELD_KEYS.filter((key) => hindsightConfigForm?.[key] !== hindsightConfigLoaded?.[key])); +} + +/** Build the POST body with TOUCHED-FIELD semantics: include a field ONLY when its + * current input differs from the loaded baseline. The apiKey baseline is "" — an + * untouched blank input is therefore never sent, so it cannot clobber a stored + * secret; an explicitly cleared field that previously held a non-empty loaded value + * is sent as "" to clear it. */ +function buildHindsightConfigDiff(form: HindsightConfigFormValues, loaded: HindsightConfigFormValues): Record { + const body: Record = {}; + if (form.mode !== loaded.mode) body.mode = form.mode; + if (form.externalUrl !== loaded.externalUrl) body.externalUrl = form.externalUrl; + if (form.uiUrl !== loaded.uiUrl) body.uiUrl = form.uiUrl; + if (form.bank !== loaded.bank) body.bank = form.bank; + if (form.namespace !== loaded.namespace) body.namespace = form.namespace; + if (form.recallScope !== loaded.recallScope) body.recallScope = form.recallScope; + if (form.autoRecall !== loaded.autoRecall) body.autoRecall = form.autoRecall; + if (form.autoRetain !== loaded.autoRetain) body.autoRetain = form.autoRetain; + if (form.timeoutMs !== loaded.timeoutMs) { + const n = Number(form.timeoutMs); + if (Number.isFinite(n) && n > 0) body.timeoutMs = n; + } + if (form.recallBudget !== loaded.recallBudget) { + const n = Number(form.recallBudget); + if (Number.isFinite(n) && n > 0) body.recallBudget = n; + } + // apiKey baseline is always "" — only sent when the user typed something. + if (form.apiKey !== loaded.apiKey) body.apiKey = form.apiKey; + return body; +} + +/** Save the inline form via the SESSIONLESS config-write seam, then re-read status + + * config so the row + form reflect the persisted values. Shows an ok/error lozenge. */ +async function handleHindsightConfigSave(pack: InstalledPackWire): Promise { + if (!hindsightConfigForm || !hindsightConfigLoaded) return; + const body = buildHindsightConfigDiff(hindsightConfigForm, hindsightConfigLoaded); + if (Object.keys(body).length === 0) { + hindsightConfigResult = { ok: true, message: "No changes" }; + renderApp(); + return; + } + busy.add("hindsight:config"); + hindsightConfigResult = null; + renderApp(); + const res = await writeBuiltinPackRoute<{ ok?: boolean; error?: string; errors?: string[] }>({ + packId: runtimeRestPackId(pack), + routeName: "config", + body, + projectId: currentProjectId(), + }); + busy.delete("hindsight:config"); + if (res.ok && res.data?.ok !== false) { + hindsightConfigResult = { ok: true, message: "Saved" }; + // Re-hydrate the form baseline + the status/config row from the persisted state. + await loadHindsightConfigForm(pack); + await loadHindsightState(); + } else { + const errs = res.ok ? (res.data?.errors ?? []).join("; ") : res.error; + hindsightConfigResult = { ok: false, message: errs || "Save failed" }; + } + renderApp(); +} + +/** Mutate one per-project override field and repaint. */ +function setHindsightOverrideField(key: "recallScope" | "bank", value: string): void { + if (!hindsightOverrideForm) hindsightOverrideForm = { recallScope: "", bank: "" }; + hindsightOverrideForm = { ...hindsightOverrideForm, [key]: value }; + renderApp(); +} + +/** Save the per-project memory override via the config route's `projectOverride` + * payload (design hindsight-memory-quality). "" recallScope / "" bank CLEAR that key + * back to the global value. The global config write path is untouched — this only + * ever sends the `projectOverride` envelope, scoped to the current projectId. */ +async function handleHindsightOverrideSave(pack: InstalledPackWire): Promise { + if (!hindsightOverrideForm) return; + const projectId = currentProjectId(); + if (!projectId) { + hindsightOverrideResult = { ok: false, message: "No active project" }; + renderApp(); + return; + } + const f = hindsightOverrideForm; + // Empty ⇒ null (clear → inherit global). Non-empty ⇒ the override value. + const projectOverride: Record = { + recallScope: f.recallScope ? f.recallScope : null, + bank: f.bank.trim() ? f.bank.trim() : null, + }; + busy.add("hindsight:override"); + hindsightOverrideResult = null; + renderApp(); + const res = await writeBuiltinPackRoute<{ ok?: boolean; error?: string; errors?: string[] }>({ + packId: runtimeRestPackId(pack), + routeName: "config", + body: { projectOverride }, + projectId, + }); + busy.delete("hindsight:override"); + if (res.ok && res.data?.ok !== false) { + // loadHindsightConfigForm resets hindsightOverrideResult to null, so set the + // success lozenge AFTER re-hydrating the form to keep it visible. + await loadHindsightConfigForm(pack); + await loadHindsightState(); + hindsightOverrideResult = { ok: true, message: "Saved" }; + } else { + const errs = res.ok ? (res.data?.errors ?? []).join("; ") : res.error; + hindsightOverrideResult = { ok: false, message: errs || "Save failed" }; + } + renderApp(); +} + +/** The compact per-project memory-override section in the inline Configure form. + * Hidden entirely unless the route exposed the overlay contract for a real project. + * Recall scope can inherit (global) or pin project/all; bank blank ⇒ inherit global. */ +function renderHindsightOverrideSection(pack: InstalledPackWire): TemplateResult | string { + if (!hindsightOverrideSupported || !hindsightOverrideProjectId) return ""; + const f = hindsightOverrideForm ?? { recallScope: "", bank: "" }; + const saving = busy.has("hindsight:override"); + const globalScope = hindsightGlobalConfig?.recallScope || "project"; + const globalBank = hindsightGlobalConfig?.bank || "bobbit"; + return html` +
    +
    Per-project override — ${hindsightProjectName()}
    +
    Overrides the global memory config for this project only. Recall scope: project = this project + shared/global memories; all = every project in the shared bank.
    +
    + + +
    +
    + + ${hindsightOverrideResult + ? html`${icon(hindsightOverrideResult.ok ? CheckCircle2 : XCircle, "xs")} ${hindsightOverrideResult.message}` + : ""} +
    +
    + `; +} + +/** The inline Configure form rendered in the Hindsight row. Mirrors the panel fields + * and distinguishes the API/data-plane URL (dialed by Bobbit) from the Dashboard UI + * URL (opened by humans, never dialed). */ +function renderHindsightConfigForm(pack: InstalledPackWire): TemplateResult { + const f = hindsightConfigForm; + const saving = busy.has("hindsight:config"); + if (!f) { + return html`
    Loading configuration…
    `; + } + const dirtyFields = hindsightConfigDirtyFields(); + const dirty = dirtyFields.size > 0; + const fieldClass = (key: keyof HindsightConfigFormValues) => `market-field ${dirtyFields.has(key) ? "market-field--dirty" : ""}`; + const fieldDirtyAttr = (key: keyof HindsightConfigFormValues) => (dirtyFields.has(key) ? "true" : "false"); + const changedLabel = (key: keyof HindsightConfigFormValues) => + dirtyFields.has(key) + ? html`changed` + : ""; + return html` +
    + ${dirty + ? html`
    ${icon(AlertTriangle, "xs")} Unsaved changes — Save changes to persist ${dirtyFields.size === 1 ? "this field" : "these fields"}.
    ` + : html`
    Configuration saved. Edit a field, then Save changes to persist it.
    `} + + + +
    + + +
    +
    + + + +
    +
    + + +
    + + ${renderHindsightOverrideSection(pack)} +
    + + + ${hindsightConfigResult + ? html`${icon(hindsightConfigResult.ok ? CheckCircle2 : XCircle, "xs")} ${hindsightConfigResult.message}` + : ""} +
    +
    + `; +} + +/** Test connection == re-read the pack `status` route (pure, no Docker). Updates the + * cached status + a transient ok/fail lozenge. */ +async function handleHindsightTest(): Promise { + busy.add("hindsight:test"); + hindsightActionResult = null; + renderApp(); + let ok = false; + let message = "Connection failed"; + const pack = installed.find((p) => p.builtin && p.packName === HINDSIGHT_PACK); + if (pack) { + // Re-read the pack `status` route over the SESSIONLESS built-in seam (pure read, + // no Docker) — the marketplace has no active chat session to mint a surface token. + const statusRes = await readBuiltinPackRoute({ + packId: runtimeRestPackId(pack), + routeName: "status", + projectId: pack.scope === "project" ? currentProjectId() : undefined, + }); + if (statusRes.ok) { + const status = statusRes.data; + hindsightStatus = status ?? null; + hindsightStatusLoaded = true; + ok = !!status?.healthy; + message = ok ? "Connected" : status?.configured ? "Not reachable" : "Not configured"; + } else { + message = statusRes.error || "Connection failed"; + } + } + busy.delete("hindsight:test"); + hindsightActionResult = { kind: "test", ok, message }; + renderApp(); +} + +/** Explicit managed-runtime start — the ONLY Docker-starting path from the marketplace. + * Fired only from the consent-disclosure confirm button (a deliberate second click). */ +async function handleHindsightStart(pack: InstalledPackWire): Promise { + busy.add("hindsight:start"); + hindsightActionResult = null; + renderApp(); + const res = await startPackRuntime({ packId: runtimeRestPackId(pack), runtimeId: HINDSIGHT_RUNTIME, projectId: pack.scope === "project" ? currentProjectId() : undefined }); + busy.delete("hindsight:start"); + hindsightStartConsentOpen = false; + if (res.ok) { + hindsightActionResult = { kind: "start", ok: true, message: "Runtime starting" }; + } else { + hindsightActionResult = { kind: "start", ok: false, message: res.error }; + } + renderApp(); + await loadHindsightState(); +} + +/** Stop the managed runtime (brings Docker down; preserves data). */ +async function handleHindsightStop(pack: InstalledPackWire): Promise { + busy.add("hindsight:stop"); + hindsightActionResult = null; + renderApp(); + const res = await stopPackRuntime({ packId: runtimeRestPackId(pack), runtimeId: HINDSIGHT_RUNTIME, projectId: pack.scope === "project" ? currentProjectId() : undefined }); + busy.delete("hindsight:stop"); + hindsightActionResult = res.ok ? { kind: "stop", ok: true, message: "Runtime stopped" } : { kind: "stop", ok: false, message: res.error }; + renderApp(); + await loadHindsightState(); +} + +/** View recent runtime logs inline (pure read). */ +async function handleHindsightLogs(pack: InstalledPackWire): Promise { + if (hindsightLogs !== null) { hindsightLogs = null; renderApp(); return; } + busy.add("hindsight:logs"); + renderApp(); + const res = await getPackRuntimeLogs({ packId: runtimeRestPackId(pack), runtimeId: HINDSIGHT_RUNTIME, projectId: pack.scope === "project" ? currentProjectId() : undefined, tail: 200 }); + busy.delete("hindsight:logs"); + hindsightLogs = res.ok ? res.data.logs : `Failed to load logs: ${res.error}`; + renderApp(); +} + +// ============================================================================ +// HINDSIGHT GUIDED SETUP WIZARD +// ============================================================================ + +const WIZARD_STEPS: Array<{ id: HindsightWizardStep; label: string }> = [ + { id: "mode", label: "Mode" }, + { id: "configure", label: "Configure" }, + { id: "connect", label: "Connect" }, + { id: "smoke", label: "Smoke test" }, +]; + +function defaultWizardForm(): HindsightWizardForm { + return { + mode: "external", + externalUrl: "", + uiUrl: "", + apiKey: "", + llmApiKey: "", + externalDatabaseUrl: "", + dataDir: "~/.hindsight", + bank: "bobbit", + namespace: "default", + recallScope: "project", + autoRecall: true, + autoRetain: true, + timeoutMs: "1500", + recallMaxInputChars: "3000", + }; +} + +function isHindsightWizardOpenFor(pack: InstalledPackWire): boolean { + return hindsightWizardOpen && hindsightWizardPackKey === `${pack.scope}:${pack.packName}`; +} + +/** Wizard-scoped runtime capability cache, keyed by the SELECTED deployment mode so + * the managed consent disclosure reflects the wizard's chosen mode BEFORE config is + * persisted (the row-level {@link runtimeCapabilities} cache keys on the server's + * CURRENT effective mode, which is still dormant/external mid-wizard). */ +const wizardRuntimeCap = new Map(); +const wizardRuntimeCapInFlight = new Set(); + +function wizardCapKey(pack: InstalledPackWire, runtimeId: string, mode: string): string { + return `${runtimeRestPackId(pack)}:${runtimeId}:${mode}`; +} + +/** Lazily fetch the capability disclosure for the wizard's selected mode (a pure GET + * — never starts Docker). Best-effort: a missing route caches `null` and the card + * falls back to static copy. */ +function ensureWizardCapabilities(pack: InstalledPackWire, runtimeId: string, mode: string): void { + const key = wizardCapKey(pack, runtimeId, mode); + if (wizardRuntimeCap.has(key) || wizardRuntimeCapInFlight.has(key)) return; + wizardRuntimeCapInFlight.add(key); + const projectId = pack.scope === "project" ? currentProjectId() : undefined; + void getPackRuntimeCapabilities({ packId: runtimeRestPackId(pack), runtimeId, projectId, mode }).then((res) => { + wizardRuntimeCapInFlight.delete(key); + wizardRuntimeCap.set(key, res.ok ? res.data : null); + renderApp(); + }); +} + +/** Open the guided wizard for a disabled built-in Hindsight row. Resets all wizard + * state to a fresh defaults form (nothing is persisted until the user runs the + * connect/start action or Finish). */ +function openHindsightWizard(pack: InstalledPackWire): void { + hindsightWizardOpen = true; + hindsightWizardPackKey = `${pack.scope}:${pack.packName}`; + hindsightWizardStep = "mode"; + hindsightWizardForm = defaultWizardForm(); + hindsightWizardConsent = false; + hindsightWizardConfigSaved = false; + hindsightWizardConnect = null; + hindsightWizardSmoke = null; + hindsightWizardError = ""; + renderApp(); +} + +/** Cancel/close the wizard. Persists nothing and leaves the pack disabled. */ +function cancelHindsightWizard(): void { + hindsightWizardOpen = false; + hindsightWizardPackKey = null; + hindsightWizardForm = null; + hindsightWizardConsent = false; + hindsightWizardConfigSaved = false; + hindsightWizardConnect = null; + hindsightWizardSmoke = null; + hindsightWizardError = ""; + renderApp(); +} + +function setWizardField(key: K, value: HindsightWizardForm[K]): void { + if (!hindsightWizardForm) hindsightWizardForm = defaultWizardForm(); + hindsightWizardForm = { ...hindsightWizardForm, [key]: value }; + // Changing mode invalidates a prior connect/start result + consent. + if (key === "mode") { + hindsightWizardConnect = null; + hindsightWizardSmoke = null; + hindsightWizardConsent = false; + } + renderApp(); +} + +function wizardGoStep(step: HindsightWizardStep): void { + hindsightWizardStep = step; + renderApp(); +} + +/** Whether the Configure step has the minimum required fields for the chosen mode. */ +function wizardConfigureReady(f: HindsightWizardForm): boolean { + if (f.mode === "external") return f.externalUrl.trim().length > 0; + if (f.mode === "managed-external-postgres") return f.externalDatabaseUrl.trim().length > 0; + return true; // managed: dataDir is defaulted; the LLM key is recommended, not required +} + +/** Build the config-route POST body for the chosen mode. Sends only the fields + * relevant to the mode + the always-relevant shared fields, so an empty optional + * secret never clobbers an unrelated stored value. */ +function buildWizardConfigBody(f: HindsightWizardForm): Record { + const body: Record = { + mode: f.mode, + bank: f.bank.trim() || "bobbit", + namespace: f.namespace.trim() || "default", + recallScope: f.recallScope, + autoRecall: f.autoRecall, + autoRetain: f.autoRetain, + }; + const t = Number(f.timeoutMs); + if (Number.isFinite(t) && t > 0) body.timeoutMs = t; + const r = Number(f.recallMaxInputChars); + if (Number.isFinite(r) && r > 0) body.recallMaxInputChars = r; + if (f.mode === "external") { + body.externalUrl = f.externalUrl.trim(); + if (f.uiUrl.trim()) body.uiUrl = f.uiUrl.trim(); + if (f.apiKey) body.apiKey = f.apiKey; + } else { + if (f.dataDir.trim()) body.dataDir = f.dataDir.trim(); + if (f.llmApiKey) body.llmApiKey = f.llmApiKey; + if (f.mode === "managed-external-postgres" && f.externalDatabaseUrl.trim()) { + body.externalDatabaseUrl = f.externalDatabaseUrl.trim(); + } + } + return body; +} + +/** Persist the wizard's config via the SESSIONLESS config-write seam (the same path + * the inline Configure form uses). Sets {@link hindsightWizardError} + returns false + * on validation/save failure. */ +async function persistWizardConfig(pack: InstalledPackWire): Promise { + if (!hindsightWizardForm) return false; + const body = buildWizardConfigBody(hindsightWizardForm); + const res = await writeBuiltinPackRoute<{ ok?: boolean; error?: string; errors?: string[] }>({ + packId: runtimeRestPackId(pack), + routeName: "config", + body, + projectId: pack.scope === "project" ? currentProjectId() : undefined, + }); + if (res.ok && res.data?.ok !== false) { + hindsightWizardConfigSaved = true; + hindsightWizardError = ""; + return true; + } + hindsightWizardError = res.ok ? ((res.data?.errors ?? []).join("; ") || res.data?.error || "Save failed") : res.error; + return false; +} + +/** EXTERNAL connect action: persist config, then re-read the `status` route (a PURE + * read — no Docker) so the health probe reflects the just-entered data-plane URL. */ +async function handleWizardTest(pack: InstalledPackWire): Promise { + busy.add("hindsight:wizard"); + hindsightWizardConnect = null; + hindsightWizardError = ""; + renderApp(); + const saved = await persistWizardConfig(pack); + if (!saved) { busy.delete("hindsight:wizard"); renderApp(); return; } + const statusRes = await readBuiltinPackRoute({ + packId: runtimeRestPackId(pack), + routeName: "status", + projectId: pack.scope === "project" ? currentProjectId() : undefined, + }); + busy.delete("hindsight:wizard"); + if (statusRes.ok) { + hindsightStatus = statusRes.data ?? null; + hindsightStatusLoaded = true; + const ok = !!statusRes.data?.healthy; + hindsightWizardConnect = { ok, message: ok ? "Connected" : "Unreachable — check the URL" }; + } else { + hindsightWizardConnect = { ok: false, message: statusRes.error || "Connection failed" }; + } + renderApp(); +} + +/** MANAGED connect action — the ONLY Docker-starting path in the wizard. Gated behind + * the consent tick; persists config, then issues a single explicit start. Polls the + * (pure) status reads afterward to surface stopped→starting→running progress. */ +async function handleWizardStart(pack: InstalledPackWire): Promise { + if (!hindsightWizardConsent || !hindsightWizardForm) return; + busy.add("hindsight:wizard"); + hindsightWizardConnect = null; + hindsightWizardError = ""; + renderApp(); + const saved = await persistWizardConfig(pack); + if (!saved) { busy.delete("hindsight:wizard"); renderApp(); return; } + const res = await startPackRuntime({ + packId: runtimeRestPackId(pack), + runtimeId: HINDSIGHT_RUNTIME, + projectId: pack.scope === "project" ? currentProjectId() : undefined, + mode: hindsightWizardForm.mode, + }); + busy.delete("hindsight:wizard"); + if (res.ok) { + hindsightWizardConnect = { ok: true, message: "Runtime starting…" }; + renderApp(); + await pollWizardManagedStatus(pack); + } else { + hindsightWizardConnect = { ok: false, message: res.error }; + renderApp(); + } +} + +/** Poll the runtime/status reads (GET only — never starts Docker) until the managed + * runtime reaches a terminal state, so the connect step shows live progress. */ +async function pollWizardManagedStatus(pack: InstalledPackWire): Promise { + for (let i = 0; i < 20 && isHindsightWizardOpenFor(pack); i++) { + await loadHindsightState(); + const rt = hindsightRuntime(); + if (rt && (rt.status === "running" || rt.status === "unhealthy" || rt.status === "docker-unavailable")) break; + await new Promise((r) => setTimeout(r, 1000)); + } +} + +/** Best-effort SMOKE TEST: re-probe the `status` route end-to-end (the only data-plane + * round-trip reachable over the sessionless seam — recall/retain are POST-only and the + * sessionless seam allows POST only for `config`). Non-fatal: a failure shows a hint + * and the user can still Finish. */ +async function handleWizardSmoke(pack: InstalledPackWire): Promise { + busy.add("hindsight:wizard"); + hindsightWizardSmoke = null; + renderApp(); + const statusRes = await readBuiltinPackRoute({ + packId: runtimeRestPackId(pack), + routeName: "status", + projectId: pack.scope === "project" ? currentProjectId() : undefined, + }); + busy.delete("hindsight:wizard"); + if (statusRes.ok) { + hindsightStatus = statusRes.data ?? null; + hindsightStatusLoaded = true; + } + if (statusRes.ok && statusRes.data?.healthy) { + hindsightWizardSmoke = { ok: true, message: "Memory data plane reachable" }; + } else { + hindsightWizardSmoke = { ok: false, message: "Could not reach the data plane yet — you can finish and retry later from the row." }; + } + renderApp(); +} + +/** Enable the built-in Hindsight pack by clearing every disabled ref (all entities + + * the runtime become active). Reuses the activation PUT seam. */ +async function enableHindsightPack(pack: InstalledPackWire): Promise { + const disabled: DisabledRefs = { + roles: [], tools: [], skills: [], entrypoints: [], + providers: [], hooks: [], mcp: [], piExtensions: [], runtimes: [], workflows: [], + }; + await savePackActivation(pack, disabled, `activation:${pack.scope}:${pack.packName}:all`); +} + +/** FINISH: ensure config is persisted (idempotent), then ENABLE the pack. The row + * then reflects the connected/running state derived from the live status. */ +async function handleWizardFinish(pack: InstalledPackWire): Promise { + busy.add("hindsight:wizard"); + hindsightWizardError = ""; + renderApp(); + if (!hindsightWizardConfigSaved) { + const ok = await persistWizardConfig(pack); + if (!ok) { busy.delete("hindsight:wizard"); renderApp(); return; } + } + await enableHindsightPack(pack); + busy.delete("hindsight:wizard"); + cancelHindsightWizard(); + await loadHindsightState(); +} + +function renderHindsightWizard(pack: InstalledPackWire): TemplateResult { + const f = hindsightWizardForm ?? defaultWizardForm(); + return html` +
    +
    +
    Set up Hindsight memory
    + +
    + ${renderWizardStepper()} + ${hindsightWizardError ? html`
    ${hindsightWizardError}
    ` : ""} +
    + ${hindsightWizardStep === "mode" + ? renderWizardModeStep(f) + : hindsightWizardStep === "configure" + ? renderWizardConfigureStep(f) + : hindsightWizardStep === "connect" + ? renderWizardConnectStep(pack, f) + : renderWizardSmokeStep(pack)} +
    +
    + `; +} + +function renderWizardStepper(): TemplateResult { + const idx = WIZARD_STEPS.findIndex((s) => s.id === hindsightWizardStep); + return html` +
      + ${WIZARD_STEPS.map((s, i) => html` +
    1. + ${i < idx ? icon(CheckCircle2, "xs") : String(i + 1)} + ${s.label} +
    2. + `)} +
    + `; +} + +function renderWizardModeStep(f: HindsightWizardForm): TemplateResult { + const card = (mode: HindsightWizardMode, ic: IconNode, title: string, blurb: string, note: string): TemplateResult => html` + + `; + return html` +

    Choose how Bobbit talks to Hindsight. You can change this later from Configure.

    +
    + ${card("external", Plug, "External", "Point Bobbit at an existing Hindsight data-plane URL you already run.", "Bobbit manages nothing — you run Hindsight + Postgres. No Docker.")} + ${card("managed", Database, "Managed (Docker)", "Bobbit runs Hindsight + Postgres locally via Docker.", "Bobbit manages containers, ports & the data volume. You provide an LLM API key + a data dir.")} + ${card("managed-external-postgres", Package, "Managed + external Postgres", "Bobbit runs only the Hindsight container against a Postgres URL you supply.", "Bobbit manages the Hindsight container. You provide a Postgres URL + an LLM API key.")} +
    +
    + + +
    + `; +} + +function renderWizardConfigureStep(f: HindsightWizardForm): TemplateResult { + const text = (key: keyof HindsightWizardForm, testid: string, placeholder: string, type = "text"): TemplateResult => html` + setWizardField(key, (e.target as HTMLInputElement).value as HindsightWizardForm[typeof key])} /> + `; + const field = (label: string, why: string, input: TemplateResult): TemplateResult => html` + + `; + const modeFields = f.mode === "external" + ? html` + ${field("API / data-plane URL", "The data-plane URL Bobbit dials for recall/retain. Required.", text("externalUrl", "market-hindsight-wizard-externalurl", "http://localhost:9177"))} + ${field("Dashboard UI URL (optional)", "The human dashboard opened by 'Open Hindsight UI'. Never dialed by Bobbit.", text("uiUrl", "market-hindsight-wizard-uiurl", "http://localhost:19177/banks/bobbit?view=data"))} + ${field("API key (optional)", "Sent as the data-plane auth header if your Hindsight requires one.", text("apiKey", "market-hindsight-wizard-apikey", "optional", "password"))} + ` + : html` + ${f.mode === "managed-external-postgres" + ? field("Postgres URL", "Bobbit points the managed Hindsight container at this Postgres. Required.", text("externalDatabaseUrl", "market-hindsight-wizard-externaldburl", "postgresql://user:pass@host:5432/db")) + : field("Data dir", "Host path the managed Postgres volume bind-mounts to (so data is on a visible local path you can back up).", text("dataDir", "market-hindsight-wizard-datadir", "~/.hindsight"))} + ${field("LLM API key", "Used by Hindsight (not Bobbit) for memory extraction. Stored as a secret.", text("llmApiKey", "market-hindsight-wizard-llmapikey", "sk-…", "password"))} + `; + return html` +

    Recommended defaults are prefilled — each field explains why.

    +
    + ${modeFields} +
    + + +
    +
    + + + +
    +
    + + +
    +
    +
    + + + +
    + `; +} + +function renderWizardConnectStep(pack: InstalledPackWire, f: HindsightWizardForm): TemplateResult { + const busyWizard = busy.has("hindsight:wizard"); + const resultLozenge = hindsightWizardConnect + ? html`${icon(hindsightWizardConnect.ok ? CheckCircle2 : XCircle, "xs")} ${hindsightWizardConnect.message}` + : ""; + + if (f.mode === "external") { + return html` +

    Test the existing Hindsight data-plane URL. Bobbit will not start a runtime in external mode.

    +
    + + ${resultLozenge} +
    +
    + + + +
    + `; + } + + // Managed / managed-external-postgres: consent-gated explicit Start (the only + // Docker-start path). Reuses the runtime consent disclosure card. + const rt = hindsightRuntime(); + const started = !!hindsightWizardConnect?.ok; + ensureWizardCapabilities(pack, HINDSIGHT_RUNTIME, f.mode); + const cap = wizardRuntimeCap.get(wizardCapKey(pack, HINDSIGHT_RUNTIME, f.mode)); + return html` +

    Start the managed Hindsight runtime. This starts Docker only after you review the disclosure and press Start Runtime.

    + ${renderRuntimeConsentCardView(HINDSIGHT_RUNTIME, cap)} + +
    + + ${resultLozenge} + ${rt ? html`${rt.status}` : ""} +
    +
    + + + +
    + `; +} + +function renderWizardSmokeStep(pack: InstalledPackWire): TemplateResult { + const busyWizard = busy.has("hindsight:wizard"); + return html` +

    Quick end-to-end check (best-effort). You can finish even if it fails.

    +
    + + ${hindsightWizardSmoke + ? html`${icon(hindsightWizardSmoke.ok ? CheckCircle2 : XCircle, "xs")} ${hindsightWizardSmoke.message}` + : ""} +
    +
    + + + +
    + `; +} + function renderConflictDetails(packConflicts: ConflictWire[]): TemplateResult { return html`
    diff --git a/src/app/marketplace.css b/src/app/marketplace.css index 71bb2b2ab..2c8edd911 100644 --- a/src/app/marketplace.css +++ b/src/app/marketplace.css @@ -505,6 +505,76 @@ max-width: 14rem; } +/* ── Managed-runtime consent enable-card (P3 design §8) ────────────────────── + * Each managed runtime gets an explicit on-enable toggle plus a disclosure card + * stating what starting it does: images/services, loopback ports, the data + * volume path and the memory/trust copy. External (no-Docker) mode swaps the + * disclosure for setup guidance. Theme tokens only. */ +.market-runtime-rows { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.market-runtime-row { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.market-runtime-card { + border: 1px solid var(--border); + border-radius: 0.5rem; + background: color-mix(in oklch, var(--info) 7%, var(--card)); + padding: 0.5rem 0.625rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.market-runtime-card--external { + background: var(--muted); +} + +.market-runtime-card-title { + display: inline-flex; + align-items: center; + gap: 0.3125rem; + font-size: 0.6875rem; + font-weight: 650; + color: var(--foreground); +} + +.market-runtime-card-text { + margin: 0; + font-size: 0.6875rem; + line-height: 1.4; + color: var(--muted-foreground); +} + +.market-runtime-card-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.1875rem 0.625rem; + margin: 0; + font-size: 0.6875rem; +} + +.market-runtime-card-grid dt { + font-weight: 600; + color: var(--muted-foreground); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.5625rem; + align-self: center; +} + +.market-runtime-card-grid dd { + margin: 0; + color: var(--foreground); + word-break: break-word; +} + /* ── Status lozenges (theme tokens only) ───────────────────────────────────── * Non-button status indicators: "Source not found" (R2) and the Browse * "Installed" marker (R4). A muted base + a warning variant. */ @@ -584,3 +654,334 @@ .market-entity-desc-text::before { content: "— "; } + +/* ── Hindsight built-in row: derived state badge + action bar (hindsight-ux-polish §5) + * Theme-token based; the state badge colour is set inline from the semantic token but + * pairs with an icon + blurb so colour is never the only signal. */ +.market-hindsight-strip { + margin-top: 0.625rem; + padding-top: 0.625rem; + border-top: 1px solid var(--border); +} + +.market-hindsight-config { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.875rem; + margin-top: 0.5rem; + font-size: 0.6875rem; +} + +.market-hindsight-config-row { + display: flex; + align-items: baseline; + gap: 0.3125rem; + min-width: 0; +} + +.market-hindsight-config-row dt { + color: var(--muted-foreground); +} + +.market-hindsight-config-row dd { + margin: 0; + font-weight: 500; + color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 18rem; +} + +.market-hindsight-logs { + font-size: 0.6875rem; + color: var(--muted-foreground); +} + +.market-hindsight-logs > summary { + cursor: pointer; + font-weight: 500; + user-select: none; + width: fit-content; +} + +.market-hindsight-logs-pre { + margin-top: 0.375rem; + padding: 0.5rem; + max-height: 16rem; + overflow: auto; + border-radius: 0.375rem; + border: 1px solid var(--border); + background: var(--muted); + color: var(--foreground); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.6875rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +/* Inline Configure form on the built-in Hindsight row. */ +.market-hindsight-config-form { + padding: 0.625rem; + border-radius: 0.5rem; + border: 1px solid var(--border); + background: color-mix(in oklch, var(--muted) 40%, transparent); +} + +.market-hindsight-config-form[data-dirty="true"] { + border-color: color-mix(in oklch, var(--warning, var(--chart-4)) 45%, var(--border)); + background: color-mix(in oklch, var(--warning, var(--chart-4)) 6%, var(--card)); +} + +.market-hindsight-unsaved, +.market-hindsight-saved { + display: inline-flex; + align-items: center; + gap: 0.25rem; + width: fit-content; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.3; +} + +.market-hindsight-unsaved { + border-color: color-mix(in oklch, var(--warning, var(--chart-4)) 40%, transparent); + background: color-mix(in oklch, var(--warning, var(--chart-4)) 12%, transparent); + color: var(--warning, var(--chart-4)); +} + +.market-hindsight-saved { + background: var(--muted); + color: var(--muted-foreground); +} + +.market-field--dirty { + padding: 0.375rem; + border-radius: 0.375rem; + outline: 1px solid color-mix(in oklch, var(--warning, var(--chart-4)) 45%, transparent); + background: color-mix(in oklch, var(--warning, var(--chart-4)) 7%, transparent); +} + +.market-field-changed { + display: inline-flex; + align-items: center; + margin-left: 0.25rem; + padding: 0.0625rem 0.3125rem; + border-radius: 999px; + border: 1px solid color-mix(in oklch, var(--warning, var(--chart-4)) 35%, transparent); + background: color-mix(in oklch, var(--warning, var(--chart-4)) 10%, transparent); + color: var(--warning, var(--chart-4)); + font-size: 0.625rem; + font-weight: 600; + line-height: 1.2; +} + +.market-btn--dirty { + box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 20%, transparent); +} + +/* Per-project memory override sub-section inside the inline Configure form. A + * subtle inset block so it reads as a scoped overlay on top of the global config. */ +.market-hindsight-override { + padding: 0.5rem 0.625rem; + border-radius: 0.5rem; + border: 1px dashed color-mix(in oklch, var(--border) 80%, var(--foreground)); + background: color-mix(in oklch, var(--chart-1) 6%, transparent); +} + +/* "Project override active" badge on the read-only summary. */ +.market-hindsight-override-badge { + gap: 0.25rem; + border-color: color-mix(in oklch, var(--chart-1) 40%, transparent); + background: color-mix(in oklch, var(--chart-1) 12%, transparent); + color: var(--foreground); +} + +.market-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.market-field-label { + font-size: 0.6875rem; + font-weight: 500; + color: var(--foreground); +} + +.market-field-help { + font-size: 0.625rem; + color: var(--muted-foreground); + line-height: 1.4; +} + +/* ── Hindsight guided setup wizard ─────────────────────────────────────────── + * Inline guided flow launched when Enable is clicked on a disabled built-in + * Hindsight row: mode → configure → connect → smoke test → finish. Theme tokens + * only; reuses .market-btn / .market-field / .market-lozenge / the runtime + * consent card. */ +.market-hindsight-wizard { + padding: 0.75rem; + border-radius: 0.5rem; + border: 1px solid color-mix(in oklch, var(--primary) 30%, var(--border)); + background: color-mix(in oklch, var(--primary) 5%, var(--card)); +} + +.market-wizard-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.market-wizard-title { + font-size: 0.8125rem; + font-weight: 650; + color: var(--foreground); +} + +.market-wizard-steps { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.875rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; + counter-reset: none; +} + +.market-wizard-step { + display: inline-flex; + align-items: center; + gap: 0.3125rem; + font-size: 0.6875rem; + color: var(--muted-foreground); + white-space: nowrap; +} + +.market-wizard-step--current { + color: var(--foreground); + font-weight: 600; +} + +.market-wizard-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.125rem; + height: 1.125rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--muted); + font-size: 0.625rem; + font-weight: 600; +} + +.market-wizard-step--current .market-wizard-step-num { + border-color: color-mix(in oklch, var(--primary) 55%, var(--border)); + background: color-mix(in oklch, var(--primary) 18%, transparent); + color: var(--primary); +} + +.market-wizard-step--done .market-wizard-step-num { + border-color: color-mix(in oklch, var(--positive) 45%, transparent); + background: color-mix(in oklch, var(--positive) 14%, transparent); + color: var(--positive); +} + +.market-wizard-help { + margin: 0; + font-size: 0.6875rem; + line-height: 1.45; + color: var(--muted-foreground); +} + +.market-wizard-fields { + display: flex; + flex-direction: column; + gap: 0.625rem; + margin-top: 0.5rem; +} + +.market-wizard-modes { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.market-wizard-mode-card { + display: flex; + flex-direction: column; + gap: 0.1875rem; + text-align: left; + padding: 0.625rem 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--border); + background: var(--background); + cursor: pointer; + pointer-events: auto; + transition: border-color 0.15s, background 0.15s; +} + +/* The card is the click target; its text/icon children must never swallow the + * pointer event (otherwise a click that lands on a child could miss the button + * in some layouts). Keeps all three mode options reliably selectable. */ +.market-wizard-mode-card > * { + pointer-events: none; +} + +.market-wizard-mode-card:hover { + border-color: color-mix(in oklch, var(--border) 55%, var(--foreground)); +} + +.market-wizard-mode-card--selected { + border-color: color-mix(in oklch, var(--primary) 55%, transparent); + background: color-mix(in oklch, var(--primary) 9%, transparent); +} + +.market-wizard-mode-title { + display: inline-flex; + align-items: center; + gap: 0.3125rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--foreground); +} + +.market-wizard-mode-blurb { + font-size: 0.6875rem; + color: var(--foreground); +} + +.market-wizard-mode-note { + font-size: 0.625rem; + color: var(--muted-foreground); + line-height: 1.4; +} + +.market-wizard-consent { + display: flex; + align-items: flex-start; + gap: 0.4375rem; + margin-top: 0.5rem; + font-size: 0.6875rem; + line-height: 1.4; + color: var(--foreground); + cursor: pointer; +} + +.market-wizard-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.625rem; + border-top: 1px dashed var(--border); +} diff --git a/src/app/pack-panels.ts b/src/app/pack-panels.ts index 2f571b3fd..2210071a1 100644 --- a/src/app/pack-panels.ts +++ b/src/app/pack-panels.ts @@ -29,6 +29,7 @@ import { html, nothing, type TemplateResult } from "lit"; import { renderHeader } from "../ui/tools/renderer-registry.js"; import { gatewayFetch } from "./gateway-fetch.js"; import { fetchContributions, type PackContributionsWire } from "./api.js"; +import { getRouteFromHash } from "./routing.js"; import { state, renderApp } from "./state.js"; import { DEFAULT_PACK_PANEL_INSTANCE_KEY, @@ -451,11 +452,16 @@ function mountPackPanelTab(reg: RegisteredPanel, params?: Record; @@ -36,6 +36,48 @@ export interface GoalProvisionedCtx { metadata: GoalMetadata; } +/** Compact server-owned summary handed to goal-completion lifecycle providers. */ +export interface GoalCompletedCtx { + goalId: string; + projectId?: string; + cwd: string; + branch?: string; + mergeTarget?: string; + parentGoalId?: string; + rootGoalId?: string; + headSha?: string; + teamLeadSessionId?: string; + completedAt: string; + pullRequest?: { + url?: string; + number?: string | number; + title?: string; + state?: string; + headSha?: string; + }; + gates: Array<{ + gateId: string; + name?: string; + status: string; + signalCount: number; + updatedAt?: number; + metadata?: Record; + content?: string; + latestCommitSha?: string; + }>; + tasks: Array<{ + id: string; + title: string; + type?: string; + state?: string; + branch?: string; + headSha?: string; + resultSummary?: string; + }>; + touchedFiles: string[]; + metadata: GoalMetadata; +} + export interface HookCtx { sessionId: string; projectId?: string; @@ -60,6 +102,24 @@ export interface HookCtx { gateway: { baseUrl: string; token: string }; } +/** Managed-runtime context injected into `ctx.runtime` for an ACTIVE managed + * provider invocation. Resolved by the host WITHOUT starting Docker. */ +export interface RuntimeContext { + baseUrl: string; + headers: Record; + status: string; +} + +/** Resolves the managed-runtime context for a provider declaring `runtime`. Returns + * `undefined` when there is no managed runtime to link (external mode, supervisor + * unavailable, runtime not running / API port unknown). NEVER starts Docker. */ +export type RuntimeContextResolver = (opts: { + packId: string; + runtimeId: string; + projectId?: string; + config: Record; +}) => Promise | RuntimeContext | undefined; + export interface HubDiagnostic { providerId: string; hook: LifecycleHook; @@ -88,6 +148,7 @@ export class LifecycleHub { private readonly globalMaxTokens: number; private readonly providerHostApi?: (opts: { sessionId: string; packId: string }) => ServerHostApi; private readonly goalMetadataResolver?: GoalMetadataResolver; + private readonly runtimeResolver?: RuntimeContextResolver; constructor(deps: { registry: PackContributionRegistry; @@ -105,6 +166,10 @@ export class LifecycleHub { * (retain queue / diagnostics) via the SAME pack-scoped, parent-authorized * path routes use. Omitted ⇒ provider hooks run without `ctx.host`. */ providerHostApi?: (opts: { sessionId: string; packId: string }) => ServerHostApi; + /** Resolves `ctx.runtime` for providers declaring a `runtime` linkage (managed + * deployment modes). Consulted per provider invocation; NEVER starts Docker. + * Omitted ⇒ providers run without `ctx.runtime` (managed modes stay dormant). */ + runtimeResolver?: RuntimeContextResolver; }) { this.registry = deps.registry; this.moduleHost = deps.moduleHost; @@ -113,6 +178,24 @@ export class LifecycleHub { this.globalMaxTokens = deps.globalMaxTokens ?? 4_000; this.providerHostApi = deps.providerHostApi; this.goalMetadataResolver = deps.goalMetadataResolver; + this.runtimeResolver = deps.runtimeResolver; + } + + private async resolveProviderRuntime( + provider: { runtime?: string; config?: Record; packRoot: string }, + projectId: string | undefined, + ): Promise { + if (!provider.runtime || !this.runtimeResolver) return undefined; + try { + return (await this.runtimeResolver({ + packId: packIdFromRoot(provider.packRoot), + runtimeId: provider.runtime, + projectId, + config: provider.config ?? {}, + })) ?? undefined; + } catch { + return undefined; // resolution failure is non-fatal — provider stays dormant + } } /** @@ -196,6 +279,56 @@ export class LifecycleHub { } } + /** + * Fire the `goalCompleted` lifecycle hook after a goal is marked complete. + * Non-fatal: provider errors/timeouts are logged into diagnostics and swallowed + * so goal completion can never be rolled back by an extension provider. + */ + async dispatchGoalCompleted(ctx: GoalCompletedCtx): Promise<{ diagnostics: HubDiagnostic[] }> { + const disabled = this.disabledProviders(ctx.goalId, ctx.projectId); + const providers = this.registry.listProviders(ctx.projectId).filter( + (p) => !disabled.has(p.id) && p.hooks.includes("goalCompleted"), + ); + const diagnostics: HubDiagnostic[] = []; + + for (const provider of providers) { + const packId = packIdFromRoot(provider.packRoot); + const runtime = await this.resolveProviderRuntime(provider, ctx.projectId); + const providerHost = this.providerHostApi?.({ sessionId: `goal:${ctx.goalId}`, packId }); + const url = pathToFileURL(path.resolve(path.dirname(provider.sourceFile), provider.module)).href; + const t0 = performance.now(); + try { + await this.moduleHost.invoke({ + url, + packRoot: provider.packRoot, + epoch: 0, + exportKind: "providers", + member: "goalCompleted", + ctx: { + ...ctx, + workingDir: ctx.cwd, + config: provider.config ?? {}, + gateway: this.gatewayInfo(), + host: providerHost, + ...(runtime ? { runtime } : {}), + } as unknown as InvokeRequest["ctx"], + arg: undefined, + workingDir: ctx.cwd, + }, provider.budget.timeoutMs); + } catch (err) { + const ms = Math.round(performance.now() - t0); + const message = err instanceof Error ? err.message : String(err); + if ((err instanceof ActionError && err.status === 504) || message.includes("timed out")) { + diagnostics.push({ providerId: provider.id, hook: "goalCompleted", timeout: true, ms }); + } else { + diagnostics.push({ providerId: provider.id, hook: "goalCompleted", error: message, ms }); + } + console.warn(`[lifecycle-hub] goalCompleted hook for provider ${provider.id} failed (non-fatal): ${message}`); + } + } + return { diagnostics }; + } + async dispatch( hook: LifecycleHook, base: Omit, @@ -207,17 +340,24 @@ export class LifecycleHub { const traceStates = new Map(); for (const provider of providers) { + const packId = packIdFromRoot(provider.packRoot); + // Managed-runtime context (P3): for a provider linked to a runtime, resolve + // `ctx.runtime` (baseUrl/headers/status) WITHOUT starting Docker. Absent for + // external mode / a stopped runtime / when no resolver is wired — the provider + // then stays dormant via its own isActive(cfg, ctx.runtime) gate. + const runtime = await this.resolveProviderRuntime(provider, base.projectId); const hookCtx: HookCtx = { ...base, config: provider.config ?? {}, budget: { maxTokens: provider.budget.maxTokens }, gateway: this.gatewayInfo(), + ...(runtime ? { runtime } : {}), }; // Provider-scoped, store-only host (least privilege). The LIVE object stays // in the parent (module-host-worker strips it before serialization) and // services the worker's proxied store calls — the durable retain queue / // diagnostics path. packId is derived from the contribution's pack root. - const providerHost = this.providerHostApi?.({ sessionId: base.sessionId, packId: packIdFromRoot(provider.packRoot) }); + const providerHost = this.providerHostApi?.({ sessionId: base.sessionId, packId }); const url = pathToFileURL(path.resolve(path.dirname(provider.sourceFile), provider.module)).href; const t0 = performance.now(); let ms = 0; diff --git a/src/server/agent/marketplace-install.ts b/src/server/agent/marketplace-install.ts index 65812c06d..518dcfae4 100644 --- a/src/server/agent/marketplace-install.ts +++ b/src/server/agent/marketplace-install.ts @@ -65,6 +65,13 @@ export interface BrowsePack extends PackManifest { export interface InstalledPackWire { scope: InstallScope; packName: string; + /** Structural pack id — the on-disk directory name, i.e. the + * `market-packs/` segment the extension-host APIs (panels, runtimes) + * key packs by via `packIdFromRoot`. Equals `packName` for installed packs + * (which install into `market-packs//`), but can DIVERGE for + * built-in packs whose shipped directory differs from their manifest name. + * The UI uses this (not `packName`) to address `/api/pack-runtimes/:id/*`. */ + packId?: string; manifest: PackManifest; meta: PackMeta; status: "ok" | "corrupt"; @@ -74,6 +81,13 @@ export interface InstalledPackWire { /** `"unknown"` when the source can't be checked (removed / never-synced / no * version data) — disambiguates "up to date" from "source unknown". */ sourceStatus: "ok" | "unknown"; + /** Mirrors `manifest.defaultDisabled`: the pack ships DORMANT (default-disabled) + * and resolves with all entities de-activated until explicitly enabled or + * already configured. Stable wire field the Marketplace UI keys on. */ + defaultDisabled?: boolean; + /** UI intent alias of {@link defaultDisabled}: enabling a default-disabled pack + * should route through the guided setup wizard rather than a bare toggle. */ + requiresGuidedSetup?: boolean; } /** Coded error so the REST layer can map to HTTP statuses. */ @@ -644,13 +658,16 @@ export class MarketplaceInstaller { const manifest = readManifest(dir); const meta = readMeta(dir); if (manifest && meta) { - rows.set(d.name, { scope: c.scope, packName: d.name, manifest, meta, status: "ok", ...this.computeSourceState(meta) }); + // `packId` (structural) === the on-disk dir name (`d.name`), which is + // what `packIdFromRoot` derives for an installed pack at this scope. + rows.set(d.name, { scope: c.scope, packName: d.name, packId: d.name, manifest, meta, status: "ok", ...this.computeSourceState(meta), defaultDisabled: manifest.defaultDisabled === true, requiresGuidedSetup: manifest.defaultDisabled === true }); } else if (manifest || meta) { // Partial / corrupt install — surface so the UI can offer cleanup. // Corrupt rows never offer an update and report an unknown source. rows.set(d.name, { scope: c.scope, packName: d.name, + packId: d.name, manifest: manifest ?? synthManifest(d.name, meta), meta: meta ?? synthMeta(d.name, c.scope, manifest), status: "corrupt", diff --git a/src/server/agent/pack-contributions.ts b/src/server/agent/pack-contributions.ts index a81b0d6dc..dd9d1d53f 100644 --- a/src/server/agent/pack-contributions.ts +++ b/src/server/agent/pack-contributions.ts @@ -9,6 +9,8 @@ // manifest.contents.entrypoints[]) // - `providers/.yaml` → ProviderContribution[] (filtered by // manifest.contents.providers[]) +// - `runtimes/.yaml` → RuntimeContribution[] (filtered by +// manifest.contents.runtimes[]) // - `pack.yaml.routes` → RouteContribution // // Mirrors the tolerance of `tool-contributions.ts`: a malformed file is warned + @@ -19,7 +21,8 @@ // 2. (duplicate host-global routeId — detected at registry build, cross-pack); // 3. duplicate panel id within a pack; // 4. duplicate entrypoint id within a pack; -// 5. duplicate provider id within a pack. +// 5. duplicate provider id within a pack; +// 6. duplicate runtime id within a pack. // // Each contribution carries its declaring `sourceFile` + the absolute `packRoot` // so the serve/import sites can resolve a path-bearing field RELATIVE to the @@ -36,6 +39,7 @@ import { isPackPathWithinRoot } from "../extension-host/path-guard.js"; // Panel ids may use dotted namespaces (e.g. `artifacts.viewer`). const PANEL_ID_RE = /^[a-z0-9][a-z0-9_.-]*$/i; const PROVIDER_ID_RE = /^[a-z0-9][a-z0-9_.-]*$/i; +const RUNTIME_ID_RE = /^[a-z0-9][a-z0-9_.-]*$/i; const ROUTE_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/; const PROVIDER_KINDS = new Set(["memory", "selector", "generic"]); const PROVIDER_HOOKS = new Set([ @@ -49,6 +53,10 @@ const PROVIDER_HOOKS = new Set([ // provider apply per-goal filesystem treatments (content-addressed marker/ // cache) without per-turn cost. See docs/design/goal-metadata.md. "goalProvisioned", + // Goal completion hook: fired once per completed goal/head after team_complete + // succeeds so memory providers can retain an outcome digest without affecting + // the completion path. + "goalCompleted", ]); /** A hard pack-contribution conflict (§5.4). Throwing aborts the pack's load so @@ -100,6 +108,24 @@ export interface RouteContribution { packRoot: string; } +/** A pack-scoped managed-runtime descriptor (runtimes/.yaml) loaded ONLY + * for basenames listed in contents.runtimes[] (P1 runtime manifest design). + * + * The loader is intentionally shallow: it enforces a valid `id` and intra-pack + * id uniqueness, then carries the raw parsed YAML as {@link manifest}. Deep + * manifest validation (compose-path containment, env/secrets/ports/modes) is + * the runtime manifest parser's responsibility (`src/server/runtime/manifest.ts`), + * applied by later orchestration phases — NOT here, keeping this phase pure. */ +export interface RuntimeContribution { + id: string; + title?: string; + description?: string; + manifest: Record; + listName: string; + sourceFile: string; + packRoot: string; +} + export interface ProviderContribution { id: string; kind: "memory" | "selector" | "generic"; @@ -118,11 +144,16 @@ export interface ProviderContribution { * for route-side validation; never handed to the provider as `ctx.config`. */ configSchema?: Record; /** Config-gated activation: the provider is omitted from the active provider - * listing until the EFFECTIVE flat config has a non-empty value for every - * key in `requiresConfig` (DisabledRefs/pack activation still wins). Enables a - * truly dormant install — no provider bridge, no per-turn hook routes, no - * network — until configured. */ - activation?: { requiresConfig: string[] }; + * listing until its EFFECTIVE flat config satisfies the gate (DisabledRefs/pack + * activation still wins). Enables a truly dormant install — no provider bridge, + * no per-turn hook routes, no network — until configured. + * + * - `requiresConfig`: every listed key must be present + (for strings) non-empty. + * - `activeWhenConfig`: an OR escape hatch for deployment-mode / runtime linkage + * — when the effective value of ANY listed key is in its allowed-value list the + * provider activates regardless of `requiresConfig`. Lets a managed deployment + * mode activate without an external URL while external mode still requires one. */ + activation?: ProviderActivation; listName: string; sourceFile: string; packRoot: string; @@ -156,16 +187,41 @@ export function resolveProviderConfigDefaults(schema: Record): return out; } -/** Parse a provider `activation` block. Only `requiresConfig: string[]` is - * recognised; anything else is dropped (tolerant). Returns `undefined` when no - * usable gating keys are present so the provider stays unconditionally active. */ -function parseProviderActivation(raw: unknown): { requiresConfig: string[] } | undefined { +/** Provider activation gate (config-gated dormancy). See {@link ProviderContribution.activation}. */ +export interface ProviderActivation { + /** AND gate: every listed key must be present + (for strings) non-empty. */ + requiresConfig?: string[]; + /** OR escape hatch keyed by config key → allowed-value list (deployment-mode / + * runtime linkage). A match activates the provider regardless of `requiresConfig`. */ + activeWhenConfig?: Record; +} + +/** Parse a provider `activation` block. `requiresConfig: string[]` and + * `activeWhenConfig: { key: string|string[] }` are recognised; anything else is + * dropped (tolerant). Returns `undefined` when no usable gating remains so the + * provider stays unconditionally active. */ +function parseProviderActivation(raw: unknown): ProviderActivation | undefined { if (!isPlainObject(raw)) return undefined; + const out: ProviderActivation = {}; const rc = raw.requiresConfig; - if (!Array.isArray(rc)) return undefined; - const keys = rc.filter((k): k is string => typeof k === "string" && k.length > 0); - if (keys.length === 0) return undefined; - return { requiresConfig: keys }; + if (Array.isArray(rc)) { + const keys = rc.filter((k): k is string => typeof k === "string" && k.length > 0); + if (keys.length > 0) out.requiresConfig = keys; + } + if (isPlainObject(raw.activeWhenConfig)) { + const map: Record = {}; + for (const [key, value] of Object.entries(raw.activeWhenConfig)) { + const values = Array.isArray(value) + ? value.filter((v): v is string => typeof v === "string" && v.length > 0) + : typeof value === "string" && value.length > 0 + ? [value] + : []; + if (values.length > 0) map[key] = values; + } + if (Object.keys(map).length > 0) out.activeWhenConfig = map; + } + if (!out.requiresConfig && !out.activeWhenConfig) return undefined; + return out; } /** All pack-scoped contributions for ONE installed pack. */ @@ -176,6 +232,7 @@ export interface PackContributions { panels: PanelContribution[]; entrypoints: EntrypointContribution[]; providers: ProviderContribution[]; + runtimes: RuntimeContribution[]; routes?: RouteContribution; } @@ -207,6 +264,7 @@ export function loadPackContributions(packRoot: string, manifest: PackManifest): panels: loadPanels(packRoot), entrypoints: loadEntrypoints(packRoot, manifest), providers: loadProviders(packRoot, manifest), + runtimes: loadRuntimes(packRoot, manifest), }; const routes = loadRoutes(packRoot, manifest); if (routes) out.routes = routes; @@ -316,6 +374,61 @@ function loadEntrypoints(packRoot: string, manifest: PackManifest): EntrypointCo return out; } +/** Load `runtimes/.yaml` ONLY for names listed in contents.runtimes[]. + * Mirrors the entrypoint/provider contribution loader pattern: safe-basename + + * realpath containment before read, tolerant warn+drop of missing/malformed + * files, and a hard {@link PackContributionError} on duplicate runtime ids + * within the pack. Accepts either `.yaml` or `.yml`. */ +function loadRuntimes(packRoot: string, manifest: PackManifest): RuntimeContribution[] { + const listNames = manifest.contents.runtimes ?? []; + const dir = path.join(packRoot, "runtimes"); + const out: RuntimeContribution[] = []; + const seenId = new Set(); + for (const listName of listNames) { + if (typeof listName !== "string" || listName.length === 0) continue; + if (!isSafeBasename(listName)) { + console.warn(`[pack-contributions] runtime listName ${JSON.stringify(listName)} is not a safe basename; skipping`); + continue; + } + let sourceFile = path.join(dir, `${listName}.yaml`); + if (!fs.existsSync(sourceFile)) { + const alt = path.join(dir, `${listName}.yml`); + if (fs.existsSync(alt)) sourceFile = alt; + } + if (!isPackPathWithinRoot(dir, sourceFile)) { + console.warn(`[pack-contributions] runtime '${listName}' resolves outside runtimes/ (${sourceFile}); skipping`); + continue; + } + let data: unknown; + try { + data = readYaml(sourceFile); + } catch (err) { + console.warn(`[pack-contributions] skipping missing/malformed runtime '${listName}' (${sourceFile}): ${String(err)}`); + continue; + } + if (!isPlainObject(data)) { + console.warn(`[pack-contributions] runtime '${listName}' (${sourceFile}) is not a mapping; dropping`); + continue; + } + const id = data.id; + if (typeof id !== "string" || !RUNTIME_ID_RE.test(id)) { + console.warn(`[pack-contributions] runtime '${listName}' (${sourceFile}) has invalid/missing id; dropping`); + continue; + } + if (seenId.has(id)) { + throw new PackContributionError( + `pack "${packIdFromRoot(packRoot)}" declares runtime id "${id}" more than once; runtime ids must be unique within a pack`, + ); + } + seenId.add(id); + const contribution: RuntimeContribution = { id, manifest: data, listName, sourceFile, packRoot }; + if (typeof data.title === "string" && data.title.length > 0) contribution.title = data.title; + if (typeof data.description === "string" && data.description.length > 0) contribution.description = data.description; + out.push(contribution); + } + return out; +} + function isPlainObject(v: unknown): v is Record { return !!v && typeof v === "object" && !Array.isArray(v); } diff --git a/src/server/agent/pack-default-activation.ts b/src/server/agent/pack-default-activation.ts new file mode 100644 index 000000000..c3dc15bd0 --- /dev/null +++ b/src/server/agent/pack-default-activation.ts @@ -0,0 +1,145 @@ +// ── Default-disabled pack activation resolution ────────────────────────────── +// +// Some built-in (server-scope) first-party packs ship DORMANT — they appear in +// the Marketplace built-in band but their contributed entities (tools, provider, +// entrypoints, managed runtime) stay de-activated on a fresh server until the +// user deliberately turns them on. Hindsight is the first such pack: a fresh +// install must NOT inject memory tools / hooks until the operator configures or +// enables it, while an EXISTING live setup (already configured) must keep working +// untouched. +// +// This module holds the PURE decision logic (the priority ladder + the helpers +// that build the synthesized "everything disabled" override and detect an +// already-configured provider). The host (server.ts) supplies the live inputs +// (stored activation refs, the explicit-enable marker, the persisted provider +// config) and injects {@link resolveDefaultActivationOverlay} into the +// ProjectConfigStore so EVERY `getPackActivation` consumer — the roles/tools +// cascade, the pack-contribution registry, the tool-manager, the slash-skills +// catalog, and the Marketplace activation endpoints — observes the same +// effective state through a single seam. +// +// The synthesized override is a READ-TIME overlay only: it is NEVER persisted, so +// the dormancy invariant (disable/uninstall returns prompts byte-identical) holds +// and an explicit user enable/disable (a real persisted activation record, or the +// explicit-enable marker) always wins. + +import type { PackManifest } from "./pack-types.js"; +import type { DisabledRefs, PackOrderScope } from "./project-config-store.js"; + +/** Pack-order scopes whose built-in packs can ship default-disabled. Built-in + * first-party packs are toggleable at SERVER scope only (§7.4), so the overlay + * applies there and is inert at `global-user`/`project`. */ +const DEFAULT_DISABLED_SCOPE: PackOrderScope = "server"; + +/** Live inputs for one (scope, pack) activation resolution. */ +export interface DefaultActivationContext { + scope: PackOrderScope; + packName: string; + /** The RAW persisted disabled-entity refs for this pack (before any overlay). */ + stored: DisabledRefs; + /** Whether the pack's manifest declares `defaultDisabled: true`. */ + isDefaultDisabled: boolean; + /** Whether the user has explicitly enabled this default-disabled pack (the + * persisted force-enabled marker carries the pack name). An explicit + * enable clears all disabled refs (empty record), which is indistinguishable + * from "never touched" — the marker disambiguates so the enable persists. */ + isForceEnabled: boolean; + /** Whether the pack is "already configured" (live-setup preservation rule). */ + isConfigured: boolean; + /** The full "every contributed entity disabled" refs for this pack, used as + * the synthesized overlay when the pack resolves dormant. */ + allDisabledRefs: DisabledRefs; +} + +/** + * Decide the EFFECTIVE disabled-refs overlay for a default-disabled pack. + * Returns the synthesized all-disabled refs when the pack must resolve DORMANT, + * or `undefined` to fall through to the raw stored refs (the normal path). + * + * Priority — an explicit user choice always wins: + * 1. non-server scope OR not default-disabled → undefined (no overlay; normal pack) + * 2. explicit stored override (non-empty refs) → undefined (honor verbatim — an + * explicit per-entity / disable-all + * record always wins, even if configured) + * 3. explicit-enable marker present → undefined (user turned it on) + * 4. already configured (live setup) → undefined (preserve the live instance) + * 5. otherwise (fresh + unconfigured + untouched) → allDisabledRefs (dormant) + */ +export function resolveDefaultActivationOverlay( + ctx: DefaultActivationContext, +): DisabledRefs | undefined { + if (ctx.scope !== DEFAULT_DISABLED_SCOPE) return undefined; + if (!ctx.isDefaultDisabled) return undefined; + if (Object.keys(ctx.stored).length > 0) return undefined; + if (ctx.isForceEnabled) return undefined; + if (ctx.isConfigured) return undefined; + return ctx.allDisabledRefs; +} + +/** Activation-ref kinds in their canonical order. Mirrors ACTIVATION_KINDS in + * project-config-store.ts (kept local to avoid a value import cycle). */ +const DISABLED_REF_KINDS = [ + "roles", + "tools", + "skills", + "entrypoints", + "providers", + "hooks", + "mcp", + "piExtensions", + "runtimes", + "workflows", +] as const; + +/** + * Build the "every contributed entity disabled" {@link DisabledRefs} for a pack + * — the synthesized overlay a dormant default-disabled pack resolves to. Mirrors + * the Marketplace UI's "disable all" (which derives the same set from the + * activation catalogue): `tools` are CONCRETE tool names (not group dir names), + * every other kind is the manifest's declared basenames. Empty kinds are omitted. + * + * @param manifest the pack manifest (contents drive most kinds) + * @param concreteTools concrete tool NAMES resolved from the pack's tool groups + * (readConcretePackToolsFromGroups), since DisabledRefs.tools + * is keyed by tool name, not by the contents.tools group dir. + */ +export function buildAllDisabledRefs( + manifest: PackManifest, + concreteTools: readonly string[], +): DisabledRefs { + const c = manifest.contents; + const byKind: Record<(typeof DISABLED_REF_KINDS)[number], readonly string[] | undefined> = { + roles: c.roles, + tools: concreteTools, + skills: c.skills, + entrypoints: c.entrypoints, + providers: c.providers, + hooks: c.hooks, + mcp: c.mcp, + piExtensions: c.piExtensions, + runtimes: c.runtimes, + workflows: c.workflows, + }; + const out: DisabledRefs = {}; + for (const kind of DISABLED_REF_KINDS) { + const arr = byKind[kind]; + if (Array.isArray(arr) && arr.length > 0) out[kind] = [...arr]; + } + return out; +} + +/** + * The "already configured" rule (live-setup preservation). A persisted provider + * config counts as configured when it has a non-empty `externalUrl` OR selects a + * managed deployment mode (`managed` / `managed-external-postgres`). Pure over a + * single provider-config object; the host calls it for each of the pack's + * providers and treats the pack as configured if ANY provider is. + */ +export function isProviderConfigConfigured(cfg: unknown): boolean { + if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return false; + const obj = cfg as Record; + const url = obj.externalUrl; + if (typeof url === "string" && url.trim().length > 0) return true; + const mode = obj.mode; + return mode === "managed" || mode === "managed-external-postgres"; +} diff --git a/src/server/agent/pack-manifest.ts b/src/server/agent/pack-manifest.ts index d742cc918..ebc3b8486 100644 --- a/src/server/agent/pack-manifest.ts +++ b/src/server/agent/pack-manifest.ts @@ -185,6 +185,9 @@ export function validateManifest( if (d.schema !== undefined) manifest.schema = schema; if (provides !== undefined) manifest.provides = provides; if (requires !== undefined) manifest.requires = requires; + // Default-disabled flag (built-in dormant packs, e.g. hindsight). Only the + // literal boolean `true` opts in; any other value is ignored (default-enabled). + if (d.defaultDisabled === true) manifest.defaultDisabled = true; // NEW (pack-schema-v1 §1.2): optional top-level `routes: { module?, names? }`. // Tolerant — a malformed routes block is dropped (no routes), never fatal. const routes = parseRoutesRef(d.routes); diff --git a/src/server/agent/pack-types.ts b/src/server/agent/pack-types.ts index a10beee78..b8acd48f0 100644 --- a/src/server/agent/pack-types.ts +++ b/src/server/agent/pack-types.ts @@ -55,6 +55,15 @@ export interface PackManifest { provides?: string[]; /** Capability names this pack depends on (schema 2+ metadata). */ requires?: string[]; + /** + * When `true`, this (built-in, server-scope) pack ships DEFAULT-DISABLED: a + * fresh server resolves it as if every contributed entity were de-activated + * (tools/provider/entrypoints/runtime all absent) UNTIL the user explicitly + * enables it OR it is already configured (live-setup preservation). Absent ⇒ + * default-enabled (the normal pack behavior). See + * src/server/agent/pack-default-activation.ts for the resolution priority. + */ + defaultDisabled?: boolean; /** * Authoritative advertised contents. v1 keys are REQUIRED but each MAY be * empty. Schema 2 adds optional pack-scoped catalogues; only `providers` has @@ -75,7 +84,7 @@ export interface PackManifest { hooks?: string[]; // hook contribution basenames (accepted, loader later) mcp?: string[]; // MCP contribution basenames (schema 2+, loader later) piExtensions?: string[]; // YAML key `pi-extensions`; loader later - runtimes?: string[]; // runtime contribution basenames (accepted, loader later) + runtimes?: string[]; // runtimes/.yaml basenames; managed-runtime descriptors workflows?: string[]; // workflow contribution basenames (accepted, loader later) }; /** Optional top-level pack-level routes (module + allowlist). Support surface, diff --git a/src/server/agent/project-config-store.ts b/src/server/agent/project-config-store.ts index 1a9df06e5..787776fe7 100644 --- a/src/server/agent/project-config-store.ts +++ b/src/server/agent/project-config-store.ts @@ -356,6 +356,16 @@ export class ProjectConfigStore { private sandboxTokens: SandboxTokenEntry[] = []; private packOrder: PackOrderMap = {}; private packActivation: PackActivationMap = {}; + /** Optional read-time overlay for default-disabled built-in packs (injected by + * server.ts). Given (scope, packName, rawStoredRefs) it returns a synthesized + * all-disabled override to make a dormant default-disabled pack resolve as + * disabled, or `undefined` to use the raw stored refs. The overlay is NEVER + * persisted — see src/server/agent/pack-default-activation.ts. */ + private defaultActivationResolver?: ( + scope: PackOrderScope, + packName: string, + stored: DisabledRefs, + ) => DisabledRefs | undefined; /** Track whether each migrated field was explicitly present on disk. */ private present = { config_directories: false, @@ -857,15 +867,32 @@ export class ProjectConfigStore { // ── Pack activation overrides (pack-schema-v1 §6.7) ────────────── - /** Read the disabled-entity refs for a pack at a scope (defensive copy). - * Missing ⇒ {} (all enabled). */ + /** Inject the default-disabled overlay resolver (server.ts wires it after the + * pack registries are built). A no-op until set; only the SERVER-scope store + * needs it (built-in packs toggle at server scope). */ + setDefaultActivationResolver( + fn: (scope: PackOrderScope, packName: string, stored: DisabledRefs) => DisabledRefs | undefined, + ): void { + this.defaultActivationResolver = fn; + } + + /** Read the EFFECTIVE disabled-entity refs for a pack at a scope (defensive + * copy). Missing ⇒ {} (all enabled), UNLESS the injected default-disabled + * overlay synthesizes an all-disabled set for a dormant built-in pack (e.g. + * Hindsight before it is enabled/configured). The overlay is read-time only — + * it never mutates or persists `this.packActivation`. */ getPackActivation(scope: PackOrderScope, packName: string): DisabledRefs { const refs = this.packActivation[scope]?.[packName]; - if (!refs) return {}; const out: DisabledRefs = {}; - for (const kind of ACTIVATION_KINDS) { - const arr = refs[kind]; - if (Array.isArray(arr) && arr.length > 0) out[kind] = [...arr]; + if (refs) { + for (const kind of ACTIVATION_KINDS) { + const arr = refs[kind]; + if (Array.isArray(arr) && arr.length > 0) out[kind] = [...arr]; + } + } + if (this.defaultActivationResolver) { + const overlay = this.defaultActivationResolver(scope, packName, out); + if (overlay) return overlay; } return out; } diff --git a/src/server/agent/provider-bridge-extension.ts b/src/server/agent/provider-bridge-extension.ts index 85dc91e35..e4487a1d1 100644 --- a/src/server/agent/provider-bridge-extension.ts +++ b/src/server/agent/provider-bridge-extension.ts @@ -9,12 +9,11 @@ * events and calls back into the gateway, which dispatches through the * `LifecycleHub`. * - * NON-NEGOTIABLE invariant: the user's message text is NEVER mutated. Recall is - * injected only into the outgoing **system-prompt tail**, delimited and - * idempotent turn-over-turn. The bridge strips any prior delimited tail before - * appending the fresh one, so the dynamic-context region never grows across - * turns. Mutating the user prompt would corrupt the transcript echo and - * re-open the comms-stack duplicate class. + * NON-NEGOTIABLE invariant: the user's message text is NEVER mutated. Per-turn + * recall is injected as a hidden custom/user-side message, never by amending + * the system prompt. This keeps provider prompt-cache system bytes stable across + * turns while preserving the transcript echo. Mutating the user prompt would + * corrupt the transcript echo and re-open the comms-stack duplicate class. * * Transport/auth mirrors `tool-guard-extension.ts`: read * `BOBBIT_GATEWAY_URL` / `BOBBIT_TOKEN`, falling back to @@ -38,7 +37,7 @@ export const DYNAMIC_CONTEXT_END = ""; export const TURN_BRIDGE_HOOKS: readonly LifecycleHook[] = ["beforePrompt", "beforeCompact"]; /** Timeout (ms) for the before-prompt callback — blocking-with-timeout. */ -export const BEFORE_PROMPT_TIMEOUT_MS = 2500; +export const BEFORE_PROMPT_TIMEOUT_MS = 5000; /** Timeout (ms) for the before-compact callback. */ export const BEFORE_COMPACT_TIMEOUT_MS = 5000; @@ -199,9 +198,10 @@ export default function(pi) { } } - // Per-turn beforePrompt: inject recall into the system-prompt TAIL only. + // Per-turn beforePrompt: inject recall as a hidden custom/user-side message. // The user's prompt text (event.prompt) is forwarded read-only and never - // mutated. + // mutated; the system prompt is never amended here so provider prompt-cache + // system bytes stay stable across turns. pi.on("before_agent_start", async (event) => { const resp = await postHook( "/provider-hooks/before-prompt", @@ -209,9 +209,15 @@ export default function(pi) { ${BEFORE_PROMPT_TIMEOUT_MS}, ); if (!resp) return undefined; // failure / timeout — proceed unchanged - const stripped = stripDelimitedTail(event.systemPrompt || ""); - const tail = typeof resp.tail === "string" ? resp.tail : ""; - return { systemPrompt: stripped + tail }; + const content = typeof resp.content === "string" ? resp.content : ""; + if (!content) return undefined; + return { + message: { + customType: "bobbit:dynamic-context", + content, + display: false, + }, + }; }); // beforeCompact: forward the about-to-be-lost span so providers can retain it diff --git a/src/server/agent/team-manager.ts b/src/server/agent/team-manager.ts index 2fffa5b75..fdeb0086b 100644 --- a/src/server/agent/team-manager.ts +++ b/src/server/agent/team-manager.ts @@ -1,5 +1,5 @@ import { execFile as execFileCb } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -21,6 +21,7 @@ import type { ColorStore } from "./color-store.js"; import type { GateStore } from "./gate-store.js"; import type { VerificationHarness } from "./verification-harness.js"; import type { ProjectContextManager } from "./project-context-manager.js"; +import type { GoalCompletedCtx } from "./lifecycle-hub.js"; import { checkGateDependencies } from "./gate-dependency-check.js"; import { anyInFlightChild } from "./team-manager-helpers.js"; import { @@ -77,6 +78,27 @@ function splitWorkerResultSummary(resultSummary: string): { summary?: string; br }; } +function extractLikelyPaths(text: string): string[] { + const out = new Set(); + const re = /(?:^|[\s'"`(])((?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+))(?:[\s'"`),.:;]|$)/g; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + const candidate = match[1]; + if (!candidate || candidate.includes("//") || candidate.length > 240) continue; + out.add(candidate); + } + return Array.from(out); +} + +async function gitNameOnly(cwd: string, args: string[]): Promise { + try { + const { stdout } = await execFile("git", ["-C", cwd, ...args], { timeout: 5_000 }); + return stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + } catch { + return []; + } +} + /** * Build a markdown list of available roles (excluding team-lead and assistant) * for injection into the team lead prompt via {{AVAILABLE_ROLES}}. @@ -182,6 +204,12 @@ export interface TeamManagerConfig { broadcastToGoal?: (goalId: string, event: any) => void; /** Project context manager for per-project store resolution */ projectContextManager?: ProjectContextManager; + /** Best-effort lifecycle dispatch after a goal is durably marked complete. */ + goalCompletedDispatcher?: (ctx: GoalCompletedCtx) => Promise | void; + /** Optional active-provider probe so disabled/missing providers do not burn an idempotency marker. */ + hasGoalCompletedProviders?: (goalId: string, projectId?: string) => boolean; + /** Optional PR cache resolver; omitted when no PR is known for the goal. */ + resolveGoalPullRequest?: (goalId: string) => GoalCompletedCtx["pullRequest"] | undefined; /** Tool manager for resolving extension paths via the cascade */ toolManager?: ToolManager; /** @@ -264,15 +292,19 @@ export class TeamManager { /** In-flight startTeam promises to prevent concurrent team creation for the same goal. */ private startTeamLocks = new Map>(); + /** In-flight goalCompleted dispatches keyed by goal id to collapse concurrent completions. */ + private goalCompletedDispatchLocks = new Map>(); + private readonly localStateDir: string; constructor(sessionManager: SessionManager, config: TeamManagerConfig, stateDir?: string) { this.sessionManager = sessionManager; this.config = config; this.taskManager = config.taskManager; + this.localStateDir = stateDir ?? bobbitStateDir(); if (config.projectContextManager) { this.localStore = null; } else { - const dir = stateDir ?? bobbitStateDir(); + const dir = this.localStateDir; this.localStore = new TeamStore(dir); // Non-PCM test path: create a local GoalManager from the same stateDir this._localGoalManager = new GoalManager(new GoalStore(dir)); @@ -2220,6 +2252,148 @@ export class TeamManager { return entry.agents.find(a => a.sessionId === sessionId); } + private async dispatchGoalCompletedOnce(goalId: string): Promise { + if (!this.config.goalCompletedDispatcher) return; + const existing = this.goalCompletedDispatchLocks.get(goalId); + if (existing) return existing; + const dispatch = this._dispatchGoalCompletedOnce(goalId).finally(() => { + this.goalCompletedDispatchLocks.delete(goalId); + }); + this.goalCompletedDispatchLocks.set(goalId, dispatch); + return dispatch; + } + + private async _dispatchGoalCompletedOnce(goalId: string): Promise { + const goal = this.resolveGoal(goalId); + if (!goal) return; + if (this.config.hasGoalCompletedProviders && !this.config.hasGoalCompletedProviders(goalId, goal.projectId)) return; + const headSha = await this.resolveGoalHeadSha(goal); + const markerKey = `goalCompleted:${goalId}:${headSha ?? "unknown"}`; + if (!this.claimGoalCompletedMarker(goalId, markerKey, headSha)) return; + const ctx = await this.buildGoalCompletedContext(goalId, goal, headSha); + try { + await this.config.goalCompletedDispatcher?.(ctx); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[team-manager] goalCompleted provider dispatch failed for ${goalId} (non-fatal): ${message}`); + } + } + + private claimGoalCompletedMarker(goalId: string, markerKey: string, headSha?: string): boolean { + try { + const dir = path.join(this.resolveStateDir(goalId), "goal-completed-markers"); + fs.mkdirSync(dir, { recursive: true }); + const digest = createHash("sha256").update(markerKey).digest("hex"); + const markerPath = path.join(dir, `${digest}.json`); + const fd = fs.openSync(markerPath, "wx"); + try { + fs.writeFileSync(fd, JSON.stringify({ markerKey, goalId, headSha, createdAt: new Date().toISOString() }, null, 2), "utf-8"); + } finally { + fs.closeSync(fd); + } + return true; + } catch (err: any) { + if (err?.code === "EEXIST") return false; + console.warn(`[team-manager] Unable to persist goalCompleted marker for ${goalId}; dispatching best-effort without marker:`, err); + return true; + } + } + + private resolveStateDir(goalId: string): string { + if (this.config.projectContextManager) { + const ctx = this.config.projectContextManager.getContextForGoal(goalId); + if (ctx) return ctx.stateDir; + } + return this.localStateDir; + } + + private async buildGoalCompletedContext(goalId: string, goal: PersistedGoal, headSha?: string): Promise { + const gateStore = this.resolveGateStore(goalId); + const gateStates = gateStore?.getGatesForGoal(goalId) ?? []; + const taskRows = this.getGoalTasks(goalId); + return { + goalId, + projectId: goal.projectId, + cwd: goal.worktreePath || goal.cwd || goal.repoPath || process.cwd(), + branch: goal.branch, + mergeTarget: goal.mergeTarget, + parentGoalId: goal.parentGoalId, + rootGoalId: goal.rootGoalId, + headSha, + teamLeadSessionId: goal.teamLeadSessionId ?? this.teams.get(goalId)?.teamLeadSessionId ?? undefined, + completedAt: new Date().toISOString(), + pullRequest: this.config.resolveGoalPullRequest?.(goalId), + gates: gateStates.map((state) => { + const def = goal.workflow?.gates.find((gate) => gate.id === state.gateId); + const latest = state.signals[state.signals.length - 1]; + return { + gateId: state.gateId, + name: def?.name, + status: state.status, + signalCount: state.signals.length, + updatedAt: state.updatedAt, + metadata: state.currentMetadata, + content: state.currentContent, + latestCommitSha: latest?.commitSha, + }; + }), + tasks: taskRows.map((task: any) => ({ + id: String(task.id), + title: String(task.title ?? task.id), + type: typeof task.type === "string" ? task.type : undefined, + state: typeof task.state === "string" ? task.state : undefined, + branch: typeof task.branch === "string" ? task.branch : undefined, + headSha: typeof task.headSha === "string" ? task.headSha : undefined, + resultSummary: typeof task.resultSummary === "string" ? task.resultSummary : undefined, + })), + touchedFiles: await this.collectTouchedFiles(goal, taskRows), + metadata: goal.metadata ?? {}, + }; + } + + private getGoalTasks(goalId: string): any[] { + try { + if (this.config.projectContextManager) { + const ctx = this.config.projectContextManager.getContextForGoal(goalId); + if (ctx) return ctx.taskStore.getByGoalId(goalId); + } + const tm: any = this.taskManager as any; + if (typeof tm.getTasksForGoal === "function") return tm.getTasksForGoal(goalId) ?? []; + if (typeof tm.getTasksByGoal === "function") return tm.getTasksByGoal(goalId) ?? []; + } catch { /* best-effort context only */ } + return []; + } + + private async collectTouchedFiles(goal: PersistedGoal, tasks: any[]): Promise { + const files = new Set(); + for (const task of tasks) { + if (task && typeof task.resultSummary === "string") { + for (const file of extractLikelyPaths(task.resultSummary)) files.add(file); + } + } + const gitCwd = goal.worktreePath || goal.cwd || goal.repoPath; + if (gitCwd) { + for (const file of await gitNameOnly(gitCwd, ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])) files.add(file); + if (files.size === 0) { + for (const file of await gitNameOnly(gitCwd, ["show", "--name-only", "--format=", "HEAD"])) files.add(file); + } + } + return Array.from(files).sort().slice(0, 200); + } + + private async resolveGoalHeadSha(goal: PersistedGoal): Promise { + const gitCwd = goal.worktreePath || goal.cwd || goal.repoPath; + if (!gitCwd) return undefined; + const ref = goal.branch && !goal.worktreePath ? goal.branch : "HEAD"; + try { + const { stdout } = await execFile("git", ["-C", gitCwd, "rev-parse", ref], { timeout: 5_000 }); + const sha = stdout.trim(); + return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : undefined; + } catch { + return undefined; + } + } + /** * Complete a team: dismiss all role agents but keep the team lead alive. * The team lead remains active to await further instructions. @@ -2281,6 +2455,10 @@ export class TeamManager { // but persist the updated state (agents cleared) this.persistEntry(goalId); + await this.dispatchGoalCompletedOnce(goalId).catch((err) => { + console.warn(`[team-manager] goalCompleted lifecycle dispatch failed for ${goalId} (non-fatal):`, err); + }); + console.log(`[team-manager] Completed team for goal ${goalId} — team lead remains active: ${entry.teamLeadSessionId}`); } diff --git a/src/server/extension-host/action-dispatcher.ts b/src/server/extension-host/action-dispatcher.ts index 5efc14e39..bb6eec877 100644 --- a/src/server/extension-host/action-dispatcher.ts +++ b/src/server/extension-host/action-dispatcher.ts @@ -41,6 +41,12 @@ export interface ActionHandlerCtx { * handler scope to the real project (e.g. a `project:` recall tag filter) * instead of fabricating one. Absent for global/server-scope sessions. */ projectId?: string; + /** Effective goal id derived from trusted session state (goalId, then teamGoalId), + * when present. Used by route handlers for server-derived context tags. */ + goalId?: string; + /** Calling session role name, when present. Used by route handlers for + * server-derived agent context tags. */ + roleName?: string; /** The session working dir — the worker's `process.cwd()` for tool parity (a * tool/MCP server runs rooted at the session worktree). Populated by the endpoint * from the persisted session. Absent for sessions with no resolvable cwd (the diff --git a/src/server/extension-host/module-host-bootstrap.ts b/src/server/extension-host/module-host-bootstrap.ts index 7eb5a7567..4357fecab 100644 --- a/src/server/extension-host/module-host-bootstrap.ts +++ b/src/server/extension-host/module-host-bootstrap.ts @@ -121,6 +121,17 @@ interface SerializableCtx { sessionId: string; toolUseId?: string; tool: string; + /** The calling session's project id (when resolvable) so a route handler can + * scope to the real project instead of fabricating one. Absent for + * global/server-scope sessions. Serialized by `module-host-worker.ts`. */ + projectId?: string; + /** Effective trusted goal/role context from the calling session, when present. */ + goalId?: string; + roleName?: string; + /** P3/P4 managed-runtime linkage (`{ baseUrl, headers, status }`) the route + * endpoint resolved for a managed-mode pack. A plain serializable object; + * absent in external mode and whenever no managed runtime is running. */ + runtime?: { baseUrl: string; headers: Record; status: string }; workingDir?: string; hostVersion?: number; hostContractVersion?: number; @@ -483,6 +494,16 @@ async function handleInvoke(msg: InvokeMessage): Promise { sessionId: msg.ctx.sessionId, toolUseId: msg.ctx.toolUseId, tool: msg.ctx.tool, + // Plumb the serialized projectId so route handlers (e.g. Hindsight + // project-scoped recall) scope to the real project rather than dropping it. + projectId: msg.ctx.projectId, + // Trusted session metadata for route-owned context tags. + goalId: msg.ctx.goalId, + roleName: msg.ctx.roleName, + // Plumb the serialized managed-runtime linkage so route handlers (e.g. + // Hindsight status/recall) dial the running managed runtime via + // ctx.runtime.baseUrl. Absent ⇒ the route stays dormant (no Docker start). + ...(msg.ctx.runtime ? { runtime: msg.ctx.runtime } : {}), workingDir: msg.ctx.workingDir, }; const result = await (fn as (c: unknown, a: unknown) => unknown)(ctx, msg.arg); diff --git a/src/server/extension-host/module-host-worker.ts b/src/server/extension-host/module-host-worker.ts index f1447e0e1..099766e56 100644 --- a/src/server/extension-host/module-host-worker.ts +++ b/src/server/extension-host/module-host-worker.ts @@ -214,6 +214,14 @@ export class ModuleHost { // The calling session's project id (when resolvable) so a route handler // can scope to the real project instead of fabricating one. projectId: (req.ctx as { projectId?: unknown } | undefined)?.projectId, + // Trusted session context for route-owned auto tags (e.g. Hindsight manual retain). + goalId: (req.ctx as { goalId?: unknown } | undefined)?.goalId, + roleName: (req.ctx as { roleName?: unknown } | undefined)?.roleName, + // P3/P4 — the managed-runtime linkage the route endpoint resolved for a + // managed-mode pack (`{ baseUrl, headers, status }`). A plain serializable + // object, so it crosses the MessagePort intact; absent in external mode and + // whenever no managed runtime is running, so the route stays dormant. + runtime: (req.ctx as { runtime?: unknown } | undefined)?.runtime, workingDir: req.ctx?.workingDir, hostVersion: (host as { version?: number } | undefined)?.version, hostContractVersion: (host as { contractVersion?: number } | undefined)?.contractVersion, diff --git a/src/server/extension-host/pack-contribution-registry.ts b/src/server/extension-host/pack-contribution-registry.ts index 7cf176db3..e1815594e 100644 --- a/src/server/extension-host/pack-contribution-registry.ts +++ b/src/server/extension-host/pack-contribution-registry.ts @@ -21,6 +21,7 @@ import { type PanelContribution, type EntrypointContribution, type ProviderContribution, + type RuntimeContribution, } from "../agent/pack-contributions.js"; import type { PackEntry, PackScope } from "../agent/pack-types.js"; @@ -36,6 +37,8 @@ export interface PackContributionResolver { getEntrypoint(projectId: string | undefined, packId: string, entrypointId: string): EntrypointContribution | undefined; /** List active provider contributions across all active packs. */ listProviders(projectId: string | undefined): ProviderContribution[]; + /** Resolve a managed-runtime descriptor within a pack. */ + getRuntime(projectId: string | undefined, packId: string, runtimeId: string): RuntimeContribution | undefined; /** True when the pack declares routeName in its routes.names allowlist. */ hasRoute(projectId: string | undefined, packId: string, routeName: string): boolean; } @@ -79,12 +82,17 @@ export class PackContributionRegistry implements PackContributionResolver { * scope, low→high precedence, already deduped-on-path * (mirrors `marketToolRoots`). * @param disabledEntrypoints Activation override lookup (§7). Absent ⇒ all enabled. + * @param disabledRuntimes Disabled-runtime activation override lookup (DisabledRefs.runtimes). + * A disabled runtime is dropped from `getPack().runtimes` / + * `getRuntime`, so the supervisor's registry lookup 404s and + * runtime listings omit it. Absent ⇒ all enabled. */ constructor( private readonly enumerate: (projectId: string | undefined) => PackEntry[], private readonly disabledEntrypoints?: DisabledEntrypointsLookup, private readonly disabledProviders?: DisabledEntrypointsLookup, private readonly providerConfigOverrides?: ProviderConfigOverrideLookup, + private readonly disabledRuntimes?: DisabledEntrypointsLookup, ) {} /** Drop the per-project index cache (rebuilt lazily on next read). */ @@ -100,6 +108,44 @@ export class PackContributionRegistry implements PackContributionResolver { return this.index(projectId).byId.get(packId); } + /** + * A single pack's RAW (activation-UNFILTERED) contributions, or undefined when + * the pack is not installed. Unlike {@link getPack}, this does NOT drop dormant + * providers (those whose `activation` gate is unsatisfied), disabled entrypoints, + * or disabled runtimes — it returns exactly what `loadPackContributions` parses + * from disk for the WINNING pack entry (highest precedence per packId). + * + * Used by the managed-runtime REST surface (`/api/pack-runtimes/:id/{capabilities, + * start,restart}`) to CLASSIFY the deployment mode/config from a pack whose + * provider is still dormant — e.g. Hindsight's external-mode `memory` provider, + * which only activates once `externalUrl` is configured. Reading the + * activation-filtered `getPack` there would misclassify fresh/default Hindsight as + * provider-less and disclose / start the Docker default mode instead of the + * external (no-Docker) setup path. Actual runtime availability (disabled-runtime + * filtering) stays enforced by the supervisor's activation-filtered registry + * lookups, NOT by this method. Providers carry their SCHEMA-DEFAULT flat config + * (`config`); callers overlay persisted store config themselves. + */ + getRawPack(projectId: string | undefined, packId: string): PackContributions | undefined { + const entries = this.enumerate(projectId); + let winning: PackEntry | undefined; + for (const e of entries) { + if (!e.manifest) continue; + if (packIdFromRoot(e.path) !== packId) continue; + winning = e; // last wins (highest precedence) + } + if (!winning?.manifest) return undefined; + try { + return loadPackContributions(winning.path, winning.manifest); + } catch (err) { + if (err instanceof PackContributionError) { + console.error(`[pack-contributions] rejecting pack at ${winning.path}: ${err.message}`); + return undefined; + } + throw err; + } + } + getPanel(projectId: string | undefined, packId: string, panelId: string): PanelContribution | undefined { return this.getPack(projectId, packId)?.panels.find((p) => p.id === panelId); } @@ -112,6 +158,10 @@ export class PackContributionRegistry implements PackContributionResolver { return this.index(projectId).list.flatMap((pack) => pack.providers); } + getRuntime(projectId: string | undefined, packId: string, runtimeId: string): RuntimeContribution | undefined { + return this.getPack(projectId, packId)?.runtimes.find((r) => r.id === runtimeId); + } + hasRoute(projectId: string | undefined, packId: string, routeName: string): boolean { const routes = this.getPack(projectId, packId)?.routes; return !!routes && routes.names.includes(routeName); @@ -182,6 +232,18 @@ export class PackContributionRegistry implements PackContributionResolver { if (resolvedProviders.length !== contrib.providers.length || resolvedProviders.some((p, i) => p !== contrib.providers[i])) { contrib = { ...contrib, providers: resolvedProviders }; } + // Runtimes: drop entries disabled via pack_activation (DisabledRefs.runtimes + // kill-switch), keyed by listName — mirrors the entrypoint/provider toggles. + // A disabled managed runtime is absent from `getPack().runtimes` / + // `getRuntime`, so the PackRuntimeSupervisor's registry lookup 404s + // (start/stop/capabilities reject) and runtime listings omit it; managed + // runtime dormancy is a deliberate activation decision. + const disabledRuntimes = this.disabledRuntimes + ? new Set(this.disabledRuntimes(e.scope, projectId, contrib.packName)) + : undefined; + if (disabledRuntimes && disabledRuntimes.size > 0) { + contrib = { ...contrib, runtimes: contrib.runtimes.filter((r) => !disabledRuntimes.has(r.listName)) }; + } loaded.push(contrib); } @@ -220,14 +282,34 @@ export class PackContributionRegistry implements PackContributionResolver { } } -/** True when a provider's `activation.requiresConfig` is satisfied by its EFFECTIVE - * flat config — every required key present and, for a string, non-empty after - * trimming. No `activation` (or empty `requiresConfig`) ⇒ unconditionally active. */ +/** True when a provider's `activation` gate is satisfied by its EFFECTIVE flat + * config. No `activation` ⇒ unconditionally active. Otherwise, in priority order: + * + * 1. `activeWhenConfig` (OR escape hatch / deployment-mode linkage): if ANY + * listed key's effective value is in its allowed-value list, the provider is + * active — this is what lets a managed deployment mode activate without an + * external URL. + * 2. `requiresConfig` (AND gate): every listed key present and, for a string, + * non-empty after trimming. + * + * When `activeWhenConfig` is declared but unmatched AND there is no `requiresConfig` + * to fall back on, the provider stays dormant (the gate was declared for a reason). */ function providerActivationSatisfied(provider: ProviderContribution): boolean { - const required = provider.activation?.requiresConfig; - if (!required || required.length === 0) return true; + const activation = provider.activation; + if (!activation) return true; const config = provider.config ?? {}; - return required.every((key) => { + const { activeWhenConfig, requiresConfig } = activation; + if (activeWhenConfig) { + for (const [key, allowed] of Object.entries(activeWhenConfig)) { + const value = config[key]; + if (typeof value === "string" && allowed.includes(value)) return true; + } + } + if (!requiresConfig || requiresConfig.length === 0) { + // No AND gate: an unmatched `activeWhenConfig` means dormant; otherwise active. + return !activeWhenConfig; + } + return requiresConfig.every((key) => { const value = config[key]; if (value === undefined || value === null) return false; if (typeof value === "string") return value.trim().length > 0; diff --git a/src/server/extension-host/route-dispatcher.ts b/src/server/extension-host/route-dispatcher.ts index c7254bbed..c582bf0ac 100644 --- a/src/server/extension-host/route-dispatcher.ts +++ b/src/server/extension-host/route-dispatcher.ts @@ -23,9 +23,20 @@ import { ModuleHost } from "./module-host-worker.js"; import { isPackPathWithinRoot } from "./path-guard.js"; import type { PackContributionResolver } from "./pack-contribution-registry.js"; +/** Managed-runtime linkage injected into a route ctx for an ACTIVE managed + * provider's pack (P3/P4). Resolved by the host WITHOUT starting Docker; absent + * in external mode and whenever the managed runtime is not running. Mirrors the + * provider-hook `ctx.runtime` shape (lifecycle-hub `RuntimeContext`). */ +export interface RouteRuntimeContext { + baseUrl: string; + headers: Record; + status: string; +} + /** The verified context handed to a route handler. Reuses the action ctx shape - * (design §5 B3.1: `RouteHandlerCtx = ActionHandlerCtx`). */ -export type RouteHandlerCtx = ActionHandlerCtx; + * (design §5 B3.1: `RouteHandlerCtx = ActionHandlerCtx`), plus the optional + * managed-runtime linkage the route endpoint resolves for managed-mode packs. */ +export type RouteHandlerCtx = ActionHandlerCtx & { runtime?: RouteRuntimeContext }; /** The single typed request a route handler receives (design §5 B3.1 / v1 * `HostRouteInit`): method + optional query/body. No raw path/URL. */ diff --git a/src/server/runtime/helpers.ts b/src/server/runtime/helpers.ts new file mode 100644 index 000000000..a67338eb3 --- /dev/null +++ b/src/server/runtime/helpers.ts @@ -0,0 +1,371 @@ +// src/server/runtime/helpers.ts +// +// P1 — Pure runtime helper utilities. +// +// These helpers prepare the INPUTS a later Docker phase will consume. They are +// deliberately PURE with respect to Docker: NO Docker CLI, NO Docker API, NO +// shelling out. The only side effects are local filesystem writes (the .env +// file) and persisted state via injected stores — both safe to exercise from +// unit tests against temp dirs. + +import crypto from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import type { + RuntimeEnvRef, + RuntimeEnvValue, + RuntimeManifest, + RuntimeModeSpec, +} from "./manifest.js"; + +// ── Secrets ──────────────────────────────────────────────────────────────── + +/** Minimal get/set surface — satisfied by SecretsStore and by test fakes. */ +export interface SecretLike { + get(key: string): string | undefined; + set(key: string, value: string): void; +} + +/** Crypto seam so tests can assert exact values; defaults to real randomBytes. */ +export interface SecretGenerator { + (): string; +} + +/** The canonical generated-secret format used across the runtime layer. */ +export function generateSecretValue(): string { + return crypto.randomBytes(24).toString("base64url"); +} + +/** + * Idempotently return a persisted secret. If a non-empty value already exists + * under `key` it is returned unchanged; otherwise a new value is generated, + * persisted via `store.set`, and returned. Repeated calls are stable. + */ +export function getOrCreateRuntimeSecret( + store: SecretLike, + key: string, + generate: SecretGenerator = generateSecretValue, +): string { + const existing = store.get(key); + if (typeof existing === "string" && existing.length > 0) return existing; + const value = generate(); + store.set(key, value); + return value; +} + +// ── Env file rendering ─────────────────────────────────────────────────────── + +/** + * Conservatively quote a value for a dotenv file. 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. + */ +export function escapeDotenvValue(value: string): string { + const escaped = value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n"); + return `"${escaped}"`; +} + +/** + * Render `env` to a dotenv file at `filePath` with stable (sorted) key order + * and mode 0600. Parent directories are created as needed. An existing file's + * mode is also corrected to 0600 (writeFileSync's mode only applies on create). + */ +export function renderRuntimeEnvFile(filePath: string, env: Record): void { + const keys = Object.keys(env).sort(); + const body = keys.map((k) => `${k}=${escapeDotenvValue(env[k] ?? "")}`).join("\n"); + const content = body.length > 0 ? `${body}\n` : ""; + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, content, { mode: 0o600 }); + // Correct the mode for a pre-existing file (writeFileSync mode is create-only). + fs.chmodSync(filePath, 0o600); +} + +// ── Host port allocation ────────────────────────────────────────────────────── + +/** Minimal numeric get/set store for persisted port assignments. */ +export interface PortStore { + get(key: string): number | undefined; + set(key: string, value: number): void; +} + +export interface PortAllocOptions { + /** Bind host for the probe. Default 127.0.0.1. */ + host?: string; +} + +function isValidPort(p: unknown): p is number { + return typeof p === "number" && Number.isInteger(p) && p >= 1 && p <= 65535; +} + +/** Probe a free ephemeral port by binding :0 and reading the assigned port. */ +export function probeFreePort(host = "127.0.0.1"): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", (err) => { + server.close(); + reject(err); + }); + server.listen(0, host, () => { + const addr = server.address(); + if (addr && typeof addr === "object") { + const port = addr.port; + server.close(() => resolve(port)); + } else { + server.close(); + reject(new Error("could not determine allocated port")); + } + }); + }); +} + +/** True if `port` can currently be bound on `host`. */ +export function isPortAvailable(port: number, host = "127.0.0.1"): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => { + server.close(); + resolve(false); + }); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); +} + +/** + * Return a persisted host port for `key`, allocating one if none is stored. + * A valid + currently-bindable persisted port is kept; otherwise a fresh port + * is probed and persisted. + */ +export async function allocateHostPort( + store: PortStore, + key: string, + opts: PortAllocOptions = {}, +): Promise { + const host = opts.host ?? "127.0.0.1"; + const existing = store.get(key); + if (isValidPort(existing) && (await isPortAvailable(existing, host))) { + return existing; + } + const port = await probeFreePort(host); + store.set(key, port); + return port; +} + +/** + * Boot-time revalidation: keep the persisted port if it is still valid AND + * available; otherwise allocate + persist a new one. Identical contract to + * {@link allocateHostPort} but named for the boot path's intent. + */ +export async function revalidateHostPort( + store: PortStore, + key: string, + opts: PortAllocOptions = {}, +): Promise { + return allocateHostPort(store, key, opts); +} + +// ── Placeholder substitution ────────────────────────────────────────────────── + +/** + * Substitute `${name}` and `${name:-default}` placeholders using `vars`. A + * placeholder whose var is missing/empty falls back to its `:-default`; with no + * default it resolves to the EMPTY string (never left as a literal `${...}`), + * so unresolved values cannot leak into the env file and `requireEnv` can detect + * a missing required value as empty. + */ +export function substitutePlaceholders(input: string, vars: Record = {}): string { + return input.replace(/\$\{([A-Za-z0-9_.-]+)(?::-(.*?))?\}/g, (_m, name: string, def?: string) => { + const v = vars[name]; + if (v !== undefined && v.length > 0) return v; + if (def !== undefined) return def; + return ""; + }); +} + +/** + * Expand a leading `~` / `~/` to the real home directory. Docker Compose does NOT + * expand `~` in `.env` values or bind-mount sources — a literal `~/.hindsight` + * would be treated as a path RELATIVE to the compose project dir (landing junk + * data under the pack root), so any home-relative path destined for compose env / + * bind mounts MUST be expanded to an absolute home path first. A value that does + * not begin with `~` (a DB URL, an absolute path, …) is returned unchanged. + */ +export function expandTilde(value: string, home: string = os.homedir()): string { + if (value === "~") return home; + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(home, value.slice(2)); + } + return value; +} + +// ── Env resolution + mode-specific invocation ──────────────────────────────── + +/** Context supplying resolved values for env refs + placeholders. */ +export interface RuntimeResolveContext { + /** User-configured secret values, keyed by SecretsStore key. */ + secrets?: Record; + /** Generated secret values, keyed by SecretsStore key. */ + generated?: Record; + /** Allocated host ports, keyed by port key. */ + ports?: Record; + /** Variables for ${name} / ${name:-default} placeholder substitution. */ + vars?: Record; +} + +/** + * Build the variable map used for `${name}` / `${name:-default}` substitution in + * literal `value` refs and plain strings. In addition to the explicit `vars`, + * this exposes resolved GENERATED secrets, USER-CONFIGURED secrets, and + * ALLOCATED ports under their own keys, so a literal can interpolate e.g. a + * generated DB password — `postgres://u:${HINDSIGHT_DB_PASSWORD}@db/...` — by its + * secret key. Explicit `vars` win on a key collision. + */ +export function buildPlaceholderVars(ctx: RuntimeResolveContext): Record { + const out: Record = {}; + if (ctx.ports) { + for (const [k, v] of Object.entries(ctx.ports)) out[k] = String(v); + } + if (ctx.secrets) Object.assign(out, ctx.secrets); + if (ctx.generated) Object.assign(out, ctx.generated); + if (ctx.vars) Object.assign(out, ctx.vars); + return out; +} + +function resolveEnvValue( + value: RuntimeEnvValue, + ctx: RuntimeResolveContext, + vars: Record, +): string { + // Literal/value-ref strings may carry a home-relative path (e.g. the default + // managed data dir `${dataDir:-~/.hindsight}`). Expand a leading `~` to an + // absolute home path so the rendered env / compose bind mount never sees a + // literal `~`. Secret/generate/port refs are opaque tokens and never expanded. + if (typeof value === "string") { + return expandTilde(substitutePlaceholders(value, vars)); + } + const ref = value as RuntimeEnvRef; + if (ref.value !== undefined) return expandTilde(substitutePlaceholders(ref.value, vars)); + if (ref.secret !== undefined) { + const v = ctx.secrets?.[ref.secret]; + if (v === undefined) throw new Error(`runtime env: missing configured secret '${ref.secret}'`); + return v; + } + if (ref.generate !== undefined) { + const v = ctx.generated?.[ref.generate]; + if (v === undefined) throw new Error(`runtime env: missing generated secret '${ref.generate}'`); + return v; + } + if (ref.port !== undefined) { + const v = ctx.ports?.[ref.port]; + if (v === undefined) throw new Error(`runtime env: missing allocated port '${ref.port}'`); + return String(v); + } + throw new Error("runtime env: empty env ref"); +} + +/** + * Resolve the effective env map for a mode by merging the manifest-level env + * with the mode-level env overlay and resolving every ref/placeholder. PURE — + * reads only from `ctx`. + */ +export function resolveRuntimeEnv( + manifest: RuntimeManifest, + mode: RuntimeModeSpec | undefined, + ctx: RuntimeResolveContext, +): Record { + const merged: Record = { ...(manifest.env ?? {}), ...(mode?.env ?? {}) }; + const vars = buildPlaceholderVars(ctx); + const out: Record = {}; + for (const name of Object.keys(merged).sort()) { + out[name] = resolveEnvValue(merged[name], ctx, vars); + } + return out; +} + +/** Inputs for building a mode-specific invocation. */ +export interface RuntimeBuildInputs { + /** Absolute path of the declaring runtime manifest file (compose anchor). */ + sourceFile: string; + /** Absolute pack root (compose containment root). */ + packRoot: string; + /** Path to the rendered .env file to hand to compose. */ + envFile: string; + /** Resolution context for env refs + placeholders. */ + ctx?: RuntimeResolveContext; +} + +/** The data-only result later consumed by the Docker phase. */ +export interface RuntimeInvocation { + runtimeId: string; + mode: string; + /** Resolved absolute compose file path (containment already enforced). */ + composeFile: string; + /** Path to the .env file. */ + envFile: string; + /** Compose services to bring up (empty = compose default). */ + services: string[]; + /** Compose profiles to activate. */ + profiles: string[]; + /** Fully resolved environment for the mode. */ + env: Record; +} + +/** + * Build a mode-specific, data-only invocation. NO Docker execution. The compose + * path is re-validated for containment (defense-in-depth); env refs/placeholders + * are resolved; the mode's `omitServices` are removed from `services`. + * + * The managed-postgres mode keeps the `db` service + managed DB env (its + * HINDSIGHT_API_DATABASE_URL is a `value` ref interpolating the GENERATED + * password via `${HINDSIGHT_DB_PASSWORD}`), while the external-postgres mode + * omits `db` and injects HINDSIGHT_API_DATABASE_URL from a configured `secret` + * ref — both expressed declaratively in the manifest. + */ +export function buildRuntimeInvocation( + manifest: RuntimeManifest, + mode: string, + inputs: RuntimeBuildInputs, +): RuntimeInvocation { + const modeSpec = manifest.modes?.[mode]; + if (!modeSpec) throw new Error(`runtime '${manifest.id}' has no mode '${mode}'`); + + const baseDir = path.dirname(path.resolve(inputs.sourceFile)); + const rootAbs = path.resolve(inputs.packRoot); + const composeAbs = path.resolve(baseDir, manifest.composeFile); + const rel = path.relative(rootAbs, composeAbs); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error(`runtime '${manifest.id}' composeFile escapes the pack root`); + } + + const ctx = inputs.ctx ?? {}; + + // Validate required env for the mode resolves before rendering. + const env = resolveRuntimeEnv(manifest, modeSpec, ctx); + for (const required of modeSpec.requireEnv ?? []) { + if (env[required] === undefined || env[required].length === 0) { + throw new Error(`runtime '${manifest.id}' mode '${mode}' requires env '${required}'`); + } + } + + const omit = new Set(modeSpec.omitServices ?? []); + const services = (modeSpec.services ?? []).filter((s) => !omit.has(s)); + const profiles = [...(modeSpec.profiles ?? [])]; + + return { + runtimeId: manifest.id, + mode, + composeFile: composeAbs, + envFile: inputs.envFile, + services, + profiles, + env, + }; +} diff --git a/src/server/runtime/index.ts b/src/server/runtime/index.ts new file mode 100644 index 000000000..c66f6166f --- /dev/null +++ b/src/server/runtime/index.ts @@ -0,0 +1,8 @@ +// src/server/runtime/index.ts +// +// P1 — Runtime manifest layer barrel. Re-exports the pure manifest +// parser/validator and the pure helper utilities. NO Docker execution lives in +// this layer. + +export * from "./manifest.js"; +export * from "./helpers.js"; diff --git a/src/server/runtime/manifest.ts b/src/server/runtime/manifest.ts new file mode 100644 index 000000000..22924cf9f --- /dev/null +++ b/src/server/runtime/manifest.ts @@ -0,0 +1,391 @@ +// src/server/runtime/manifest.ts +// +// P1 — Runtime manifest parser/validator (PURE). +// +// Parses pack-authored `runtimes/.yaml` descriptors into a typed +// {@link RuntimeManifest}. This module is intentionally PURE: it performs NO +// Docker CLI/API calls, NO compose expansion, and NO filesystem reads of the +// compose file itself. The only path work it does is a LEXICAL containment +// check that rejects a `composeFile` which — after being resolved relative to +// the declaring manifest file — would escape the pack root (the explicit +// "compose-path escape rejection" requirement of the P1 design). +// +// Validation is tolerant in the same spirit as the pack-contribution loaders: +// problems are pushed onto an optional `problems[]` sink and the parse returns +// `null` for an unusable manifest rather than throwing. + +import path from "node:path"; +import { parse } from "yaml"; +import { isSafeRelativePath } from "../agent/tool-contributions.js"; + +/** Runtime/runtime-mode ids: lowercase-friendly, dotted/dashed allowed. */ +const RUNTIME_ID_RE = /^[a-z0-9][a-z0-9_.-]*$/i; +/** Env var names: conventional shell-env identifiers. */ +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** Persistence keys for secrets/ports: safe key tokens. */ +const KEY_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/; + +/** A declarative reference for an env value that is resolved at build time. */ +export interface RuntimeEnvRef { + /** Resolve from a USER-CONFIGURED secret (e.g. the LLM API key). */ + secret?: string; + /** Resolve from a GENERATED+persisted secret (idempotent). */ + generate?: string; + /** Resolve from an allocated host port (rendered as a string). */ + port?: string; + /** Literal value (supports ${var} / ${var:-default} placeholders). */ + value?: string; +} + +export type RuntimeEnvValue = string | RuntimeEnvRef; + +/** A secret the runtime needs available before launch. */ +export interface RuntimeSecretSpec { + /** SecretsStore key under which the value lives / is persisted. */ + key: string; + /** When true the value is generated+persisted; otherwise user-supplied. */ + generate?: boolean; + /** Optional env var name to expose the value under. */ + env?: string; +} + +/** A host port the runtime needs allocated + persisted. */ +export interface RuntimePortSpec { + /** Persistence key for the chosen host port. */ + key: string; + /** Optional env var name to expose the chosen port under. */ + env?: string; + /** Informational container-side port. */ + container?: number; +} + +/** A launch mode (e.g. managed-postgres vs external-postgres). */ +export interface RuntimeModeSpec { + title?: string; + /** Compose services to bring up for this mode. */ + services?: string[]; + /** Compose profiles to activate for this mode. */ + profiles?: string[]; + /** Services explicitly excluded (external-postgres omits `db`). */ + omitServices?: string[]; + /** Mode-specific env overlay (merged over manifest.env). */ + env?: Record; + /** Env var names that MUST be supplied for this mode (validation hint). */ + requireEnv?: string[]; +} + +export interface RuntimeManifest { + id: string; + title?: string; + description?: string; + /** Pack-relative compose file, resolved relative to the manifest's dir. */ + composeFile: string; + env?: Record; + secrets?: RuntimeSecretSpec[]; + ports?: RuntimePortSpec[]; + modes?: Record; +} + +function note(problems: string[] | undefined, msg: string): void { + if (problems) problems.push(msg); +} + +/** True for a syntactically valid runtime/mode id. */ +export function isSafeRuntimeId(id: unknown): id is string { + return typeof id === "string" && id.length > 0 && RUNTIME_ID_RE.test(id); +} + +/** + * LEXICAL containment check for a pack-relative compose path. Resolves + * `composeFile` relative to the directory of `sourceFile`, then requires the + * result to stay inside `packRoot`. PURE — never touches the filesystem. + * + * @returns the resolved absolute compose path when contained, else null. + */ +export function resolveContainedComposePath( + composeFile: string, + sourceFile: string, + packRoot: string, +): string | null { + if (!isSafeRelativePath(composeFile)) return null; + const baseDir = path.dirname(path.resolve(sourceFile)); + const rootAbs = path.resolve(packRoot); + const composeAbs = path.resolve(baseDir, composeFile); + const rel = path.relative(rootAbs, composeAbs); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null; + return composeAbs; +} + +function validateEnvValue(raw: unknown, label: string, problems?: string[]): RuntimeEnvValue | null { + if (typeof raw === "string") return raw; + if (typeof raw === "number" || typeof raw === "boolean") return String(raw); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + note(problems, `${label}: env value must be a string or {secret|generate|port|value} ref`); + return null; + } + const obj = raw as Record; + const ref: RuntimeEnvRef = {}; + let count = 0; + for (const k of ["secret", "generate", "port", "value"] as const) { + const v = obj[k]; + if (v === undefined) continue; + if (typeof v !== "string" || v.length === 0) { + note(problems, `${label}: env ref '${k}' must be a non-empty string`); + return null; + } + (ref as Record)[k] = v; + count++; + } + if (count !== 1) { + note(problems, `${label}: env ref must declare exactly one of secret|generate|port|value`); + return null; + } + return ref; +} + +function validateEnvMap( + raw: unknown, + label: string, + problems?: string[], +): Record | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + note(problems, `${label} must be a mapping`); + return null; + } + const out: Record = {}; + for (const [name, v] of Object.entries(raw as Record)) { + if (!ENV_NAME_RE.test(name)) { + note(problems, `${label}: invalid env name ${JSON.stringify(name)}`); + return null; + } + const val = validateEnvValue(v, `${label}.${name}`, problems); + if (val === null) return null; + out[name] = val; + } + return out; +} + +function validateStringArray(raw: unknown, label: string, problems?: string[]): string[] | null { + if (!Array.isArray(raw)) { + note(problems, `${label} must be an array of strings`); + return null; + } + const out: string[] = []; + for (const v of raw) { + if (typeof v !== "string" || v.length === 0) { + note(problems, `${label} entries must be non-empty strings`); + return null; + } + out.push(v); + } + return out; +} + +function validateSecrets(raw: unknown, problems?: string[]): RuntimeSecretSpec[] | null { + if (!Array.isArray(raw)) { + note(problems, "secrets must be an array"); + return null; + } + const out: RuntimeSecretSpec[] = []; + const seen = new Set(); + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + note(problems, "secrets[]: each entry must be a mapping"); + return null; + } + const obj = item as Record; + if (typeof obj.key !== "string" || !KEY_RE.test(obj.key)) { + note(problems, `secrets[]: invalid key ${JSON.stringify(obj.key)}`); + return null; + } + if (seen.has(obj.key)) { + note(problems, `secrets[]: duplicate key ${JSON.stringify(obj.key)}`); + return null; + } + seen.add(obj.key); + const spec: RuntimeSecretSpec = { key: obj.key }; + if (obj.generate !== undefined) { + if (typeof obj.generate !== "boolean") { + note(problems, `secrets[${obj.key}]: generate must be a boolean`); + return null; + } + spec.generate = obj.generate; + } + if (obj.env !== undefined) { + if (typeof obj.env !== "string" || !ENV_NAME_RE.test(obj.env)) { + note(problems, `secrets[${obj.key}]: invalid env name`); + return null; + } + spec.env = obj.env; + } + out.push(spec); + } + return out; +} + +function validatePorts(raw: unknown, problems?: string[]): RuntimePortSpec[] | null { + if (!Array.isArray(raw)) { + note(problems, "ports must be an array"); + return null; + } + const out: RuntimePortSpec[] = []; + const seen = new Set(); + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + note(problems, "ports[]: each entry must be a mapping"); + return null; + } + const obj = item as Record; + if (typeof obj.key !== "string" || !KEY_RE.test(obj.key)) { + note(problems, `ports[]: invalid key ${JSON.stringify(obj.key)}`); + return null; + } + if (seen.has(obj.key)) { + note(problems, `ports[]: duplicate key ${JSON.stringify(obj.key)}`); + return null; + } + seen.add(obj.key); + const spec: RuntimePortSpec = { key: obj.key }; + if (obj.env !== undefined) { + if (typeof obj.env !== "string" || !ENV_NAME_RE.test(obj.env)) { + note(problems, `ports[${obj.key}]: invalid env name`); + return null; + } + spec.env = obj.env; + } + if (obj.container !== undefined) { + if (typeof obj.container !== "number" || !Number.isInteger(obj.container) || obj.container < 1 || obj.container > 65535) { + note(problems, `ports[${obj.key}]: container must be an integer in 1..65535`); + return null; + } + spec.container = obj.container; + } + out.push(spec); + } + return out; +} + +function validateModes(raw: unknown, problems?: string[]): Record | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + note(problems, "modes must be a mapping"); + return null; + } + const out: Record = {}; + for (const [name, v] of Object.entries(raw as Record)) { + if (!isSafeRuntimeId(name)) { + note(problems, `modes: invalid mode id ${JSON.stringify(name)}`); + return null; + } + if (!v || typeof v !== "object" || Array.isArray(v)) { + note(problems, `modes.${name} must be a mapping`); + return null; + } + const obj = v as Record; + const spec: RuntimeModeSpec = {}; + if (typeof obj.title === "string") spec.title = obj.title; + if (obj.services !== undefined) { + const arr = validateStringArray(obj.services, `modes.${name}.services`, problems); + if (arr === null) return null; + spec.services = arr; + } + if (obj.profiles !== undefined) { + const arr = validateStringArray(obj.profiles, `modes.${name}.profiles`, problems); + if (arr === null) return null; + spec.profiles = arr; + } + if (obj.omitServices !== undefined) { + const arr = validateStringArray(obj.omitServices, `modes.${name}.omitServices`, problems); + if (arr === null) return null; + spec.omitServices = arr; + } + if (obj.requireEnv !== undefined) { + const arr = validateStringArray(obj.requireEnv, `modes.${name}.requireEnv`, problems); + if (arr === null) return null; + spec.requireEnv = arr; + } + if (obj.env !== undefined) { + const env = validateEnvMap(obj.env, `modes.${name}.env`, problems); + if (env === null) return null; + spec.env = env; + } + out[name] = spec; + } + return out; +} + +/** + * Validate an already-parsed runtime descriptor. Returns the typed manifest or + * null; problems (when provided) accumulate human-readable reasons. + */ +export function validateRuntimeManifest( + data: unknown, + sourceFile: string, + packRoot: string, + problems?: string[], +): RuntimeManifest | null { + if (!data || typeof data !== "object" || Array.isArray(data)) { + note(problems, "runtime manifest must be a mapping"); + return null; + } + const obj = data as Record; + + if (!isSafeRuntimeId(obj.id)) { + note(problems, `runtime manifest has invalid/missing id ${JSON.stringify(obj.id)}`); + return null; + } + if (typeof obj.composeFile !== "string" || obj.composeFile.length === 0) { + note(problems, "runtime manifest is missing composeFile"); + return null; + } + if (resolveContainedComposePath(obj.composeFile, sourceFile, packRoot) === null) { + note(problems, `composeFile ${JSON.stringify(obj.composeFile)} escapes the pack root or is unsafe`); + return null; + } + + const manifest: RuntimeManifest = { id: obj.id, composeFile: obj.composeFile }; + if (typeof obj.title === "string") manifest.title = obj.title; + if (typeof obj.description === "string") manifest.description = obj.description; + + if (obj.env !== undefined) { + const env = validateEnvMap(obj.env, "env", problems); + if (env === null) return null; + manifest.env = env; + } + if (obj.secrets !== undefined) { + const secrets = validateSecrets(obj.secrets, problems); + if (secrets === null) return null; + manifest.secrets = secrets; + } + if (obj.ports !== undefined) { + const ports = validatePorts(obj.ports, problems); + if (ports === null) return null; + manifest.ports = ports; + } + if (obj.modes !== undefined) { + const modes = validateModes(obj.modes, problems); + if (modes === null) return null; + manifest.modes = modes; + } + + return manifest; +} + +/** + * Parse runtime YAML text into a validated {@link RuntimeManifest}. Returns + * null (and records the reason) on malformed YAML or failed validation. + */ +export function parseRuntimeManifest( + raw: string, + sourceFile: string, + packRoot: string, + problems?: string[], +): RuntimeManifest | null { + let data: unknown; + try { + data = parse(raw); + } catch (err) { + note(problems, `runtime manifest ${sourceFile} is not valid YAML: ${String(err)}`); + return null; + } + return validateRuntimeManifest(data, sourceFile, packRoot, problems); +} diff --git a/src/server/runtimes/index.ts b/src/server/runtimes/index.ts new file mode 100644 index 000000000..c69bd2319 --- /dev/null +++ b/src/server/runtimes/index.ts @@ -0,0 +1,7 @@ +// src/server/runtimes/index.ts +// +// P2 — Managed pack-runtime supervisor barrel. Re-exports the Docker-backed +// supervisor + its public types/helpers. All Docker execution lives here, NOT +// in the pure P1 `src/server/runtime/*` layer. + +export * from "./pack-runtime-supervisor.js"; diff --git a/src/server/runtimes/pack-runtime-supervisor.ts b/src/server/runtimes/pack-runtime-supervisor.ts new file mode 100644 index 000000000..7180abd72 --- /dev/null +++ b/src/server/runtimes/pack-runtime-supervisor.ts @@ -0,0 +1,1582 @@ +// src/server/runtimes/pack-runtime-supervisor.ts +// +// P2 — Docker-backed managed-runtime supervisor. +// +// Builds on the PURE P1 runtime manifest layer (`src/server/runtime/*`). This +// module is the ONLY place that shells out to Docker for managed pack runtimes. +// All Docker invocation goes through `execFile` (never a shell string) via an +// injectable executor so unit/API tests can fully mock Docker — production code +// must never run a real Docker daemon during automated tests. +// +// Design: docs/design — "P2 PackRuntimeSupervisor + REST design". + +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile as execFileCb } from "node:child_process"; +import { promisify } from "node:util"; + +import type { PackContributionResolver } from "../extension-host/pack-contribution-registry.js"; +import type { RuntimeContribution } from "../agent/pack-contributions.js"; +import { + validateRuntimeManifest, + resolveContainedComposePath, + type RuntimeManifest, +} from "../runtime/manifest.js"; +import { + buildRuntimeInvocation, + renderRuntimeEnvFile, + getOrCreateRuntimeSecret, + generateSecretValue, + allocateHostPort, + probeFreePort, + substitutePlaceholders, + type RuntimeInvocation, + type RuntimeResolveContext, + type SecretLike, + type PortStore, +} from "../runtime/helpers.js"; + +const execFileAsync = promisify(execFileCb); + +// ── Public types ───────────────────────────────────────────────────────────── + +export type PackRuntimeStatusState = + | "docker-unavailable" + | "stopped" + | "starting" + | "running" + | "unhealthy"; + +export interface PackRuntimeServiceStatus { + name: string; + state?: string; + health?: string; +} + +export interface PackRuntimeDescriptor { + /** Stable, URL-safe, reversible API id (see {@link encodePackRuntimeId}). */ + id: string; + packId: string; + packName?: string; + runtimeId: string; + title?: string; + description?: string; +} + +export interface PackRuntimeStatus extends PackRuntimeDescriptor { + status: PackRuntimeStatusState; + mode?: string; + composeProject: string; + services?: PackRuntimeServiceStatus[]; + message?: string; +} + +/** + * Start policy for a managed runtime (P3 — consent/activation layer). + * + * - `manual` : the runtime NEVER starts implicitly. A user must explicitly + * start it (runtime UI / `POST /api/pack-runtimes/:id/start`). + * - `on-enable`: enabling the runtime via the marketplace pack-activation toggle + * IS the explicit user start action — and the ONLY implicit-start + * trigger. Boot, install, update, list and status must still never + * bring the runtime up. + * + * Existing descriptors with no declared policy default to `manual` (no + * auto-start), preserving the P2 behaviour. + */ +export type PackRuntimeStartPolicy = "manual" | "on-enable"; + +/** One declared host port in a {@link PackRuntimeCapabilitySummary}. */ +export interface PackRuntimeCapabilityPort { + /** Manifest persistence/env key (e.g. HINDSIGHT_API_PORT). */ + key: string; + /** Env var name the chosen host port is exposed under, when declared. */ + env?: string; + /** Informational container-side port. */ + container?: number; + /** Allocated/persisted host port when one is already known (never allocates). */ + host?: number; +} + +/** + * Pre-start consent disclosure for a managed runtime (P3 §8). Derived purely + * from the validated manifest + selected mode + already-persisted ports — it + * NEVER touches Docker and NEVER allocates new ports/secrets, so it is safe to + * render before the user has consented to a start. + */ +export interface PackRuntimeCapabilitySummary extends PackRuntimeDescriptor { + /** Selected (or default) runtime mode the summary describes. */ + mode: string; + /** Whether enabling this runtime starts it (`on-enable`) or not (`manual`). */ + startPolicy: PackRuntimeStartPolicy; + /** Collision-guarded compose project name. */ + composeProject: string; + /** Compose services started for the selected mode (after `omitServices`). */ + services: string[]; + /** Service/image names disclosed to the user (currently the service list). */ + images: string[]; + /** Declared host ports + their persisted host assignment when known. */ + ports: PackRuntimeCapabilityPort[]; + /** Effective data/volume path for managed data (e.g. ~/.hindsight). */ + volumePath?: string; + /** First-party memory/trust disclosure copy. */ + trust: string; +} + +/** Options/result shapes for the injectable Docker executor. */ +export interface DockerExecOptions { + env: NodeJS.ProcessEnv; + timeout: number; + windowsHide: boolean; + maxBuffer: number; + encoding: "utf-8"; +} +export interface DockerExecResult { + stdout: string; + stderr: string; +} +export type DockerExecutor = ( + file: string, + args: readonly string[], + options: DockerExecOptions, +) => Promise; + +export interface PackRuntimeSupervisorOptions { + /** Resolves active pack runtime contributions for a project scope. */ + registry: PackContributionResolver; + /** Docker executable; defaults to `process.env.DOCKER_BIN || "docker"`. */ + dockerBin?: string; + /** Docker invocation seam; defaults to promisified `execFile`. */ + executor?: DockerExecutor; + /** HTTP readiness probe seam; defaults to a `fetch`-based GET probe. */ + httpProbe?: HttpHealthProbe; + /** Per-server suffix on compose project names (collision guard, §15.5). */ + serverIdentitySuffix?: string; + /** Max time to wait for a runtime to become healthy after `up -d`. */ + startupTimeoutMs?: number; + /** Health re-poll interval during startup. */ + pollIntervalMs?: number; + /** Per-Docker-command timeout. */ + commandTimeoutMs?: number; + /** Where rendered `.env` files live (one dir per compose project). */ + runtimeDataDir?: string; + /** Clock seam (deterministic timeout tests). Defaults to `Date.now`. */ + now?: () => number; + /** Sleep seam (deterministic timeout tests). Defaults to real timers. */ + sleep?: (ms: number) => Promise; + /** + * Persisted user-configured + generated secret store (satisfied by the + * production `SecretsStore`). When supplied, the default resolver context + * idempotently generates+persists `generate: true` secrets and reads + * user-configured `secret:` values from it. When absent, generated secrets + * fall back to ephemeral values and user-configured secrets are unresolved + * (a runtime that actually references one then fails with a clear error). + */ + secretsStore?: SecretLike; + /** + * Persisted host-port store (satisfied by {@link FilePortStore}). When + * supplied, the default resolver context allocates+persists a stable host + * port per declared `port` key; otherwise an ephemeral free port is probed. + */ + portStore?: PortStore; + /** + * Resolver context builder for env refs/placeholders. When omitted, a + * production-safe default resolves declared generated secrets + ports (and + * user-configured secrets from {@link secretsStore}) so real pack runtimes + * no longer throw in `buildRuntimeInvocation` before Docker starts. May be + * async (port allocation is async). + */ + buildContext?: ( + manifest: RuntimeManifest, + contribution: RuntimeContribution, + ) => RuntimeResolveContext | Promise; +} + +// ── Errors (mappable to HTTP codes by the REST layer) ──────────────────────── + +/** Unknown pack/runtime → REST should answer 404. */ +export class PackRuntimeNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "PackRuntimeNotFoundError"; + } +} + +/** Malformed id / mode / tail → REST should answer 400. */ +export class PackRuntimeBadRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "PackRuntimeBadRequestError"; + } +} + +/** + * Docker executable not found (`ENOENT`). Operations that return a + * {@link PackRuntimeStatus} surface this as a `docker-unavailable` state; the + * string-returning {@link PackRuntimeSupervisor.logs} throws this instead so the + * REST layer can answer a consistent `docker-unavailable` shape rather than + * hiding a Docker-installation failure behind empty output. + */ +export class PackRuntimeDockerUnavailableError extends Error { + constructor(message = "docker is not available") { + super(message); + this.name = "PackRuntimeDockerUnavailableError"; + } +} + +// ── File-backed host-port store (production default) ───────────────────────── + +/** + * Minimal JSON-file-backed {@link PortStore} for persisted host-port + * assignments, mirroring `SecretsStore`'s on-disk discipline. Read/write errors + * are swallowed (best-effort persistence) so a corrupt file degrades to fresh + * allocation rather than crashing the supervisor. + */ +export class FilePortStore implements PortStore { + private data: Record = {}; + private readonly filePath: string; + + constructor(filePath: string) { + this.filePath = filePath; + try { + if (fs.existsSync(filePath)) { + const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const [k, v] of Object.entries(raw)) { + if (typeof v === "number" && Number.isInteger(v)) this.data[k] = v; + } + } + } + } catch { + /* best effort — start fresh on unreadable/corrupt state */ + } + } + + get(key: string): number | undefined { + return this.data[key]; + } + + set(key: string, value: number): void { + this.data[key] = value; + this._persist(); + } + + /** Drop a persisted port assignment (purge path). Best-effort persistence. */ + remove(key: string): void { + if (!(key in this.data)) return; + delete this.data[key]; + this._persist(); + } + + private _persist(): void { + try { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + fs.writeFileSync(this.filePath, `${JSON.stringify(this.data, null, 2)}\n`, "utf-8"); + } catch { + /* best effort */ + } + } +} + +/** + * Get or create a STABLE per-server identity suffix for compose project names, + * persisted under the gateway state dir (`/pack-runtimes/server-identity`). + * + * Compose project names are `bobbit-pack--` ({@link PackRuntimeSupervisor.composeProjectFor}). + * The suffix guards against collisions between concurrent Bobbit servers sharing a + * host, but it MUST stay stable across gateway process restarts — otherwise a + * restart would compute a different project name and orphan the still-running + * containers (they'd no longer be addressable via `compose -p `). + * + * Production wiring passes this value as `serverIdentitySuffix`, so the supervisor + * never falls back to the per-process random suffix in `opts.serverIdentitySuffix ?? + * crypto.randomBytes(...)`. Read errors / a blank file degrade to (re)creating the + * identity; a write error degrades to the in-memory value for this process only. + */ +export function getOrCreatePackRuntimeServerIdentity(stateDir: string): string { + const file = path.join(stateDir, "pack-runtimes", "server-identity"); + try { + if (fs.existsSync(file)) { + const existing = fs.readFileSync(file, "utf-8").trim(); + if (existing) return existing; + } + } catch { + /* unreadable — fall through to (re)create */ + } + const identity = crypto.randomBytes(4).toString("hex"); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${identity}\n`, "utf-8"); + } catch { + /* best effort — use the in-memory value for this process */ + } + return identity; +} + +/** + * Read a runtime's declared {@link PackRuntimeStartPolicy} from its RAW manifest + * object (the un-validated `RuntimeContribution.manifest`). Anything other than + * the literal `"on-enable"` — including an absent field — resolves to `manual`, + * so existing descriptors keep their no-auto-start P2 behaviour. + */ +export function readRuntimeStartPolicy(manifest: Record | undefined): PackRuntimeStartPolicy { + return manifest?.startPolicy === "on-enable" ? "on-enable" : "manual"; +} + +/** + * A runtime's declared HTTP startup-readiness probe. Read from the RAW manifest + * object (the validated {@link RuntimeManifest} intentionally ignores this + * supervisor-only field, exactly like `startPolicy`). After `compose up -d`, the + * supervisor polls `http://127.0.0.1:` and + * only completes `start`/`ensureRuntime` as `running` once it returns HTTP 200 + * (or `unhealthy` on timeout) — a compose-ps "running" alone is NOT sufficient. + */ +export interface RuntimeHealthcheck { + /** Informational compose service the probe targets (disclosure only). */ + service?: string; + /** Declared port KEY (matches a `ports[].key`) whose resolved host port is probed. */ + port: string; + /** HTTP path probed on `127.0.0.1:` (e.g. `/health`). */ + path: string; + /** Re-poll interval while waiting; falls back to the supervisor default. */ + intervalMs?: number; + /** Max time to wait for HTTP 200; falls back to the supervisor default. */ + startupTimeoutMs?: number; +} + +/** + * Read a runtime's declared {@link RuntimeHealthcheck} from its RAW manifest. A + * missing/malformed block (or one without both a non-empty `path` and `port`) + * resolves to `null`, so a runtime with no HTTP probe keeps the compose-ps-only + * readiness behaviour. + */ +export function readRuntimeHealthcheck(manifest: Record | undefined): RuntimeHealthcheck | null { + const hc = manifest?.healthcheck; + if (!hc || typeof hc !== "object" || Array.isArray(hc)) return null; + const o = hc as Record; + const probePath = typeof o.path === "string" && o.path.length > 0 ? o.path : undefined; + const port = typeof o.port === "string" && o.port.length > 0 ? o.port : undefined; + if (!probePath || !port) return null; + const out: RuntimeHealthcheck = { port, path: probePath }; + if (typeof o.service === "string" && o.service.length > 0) out.service = o.service; + if (typeof o.intervalMs === "number" && Number.isFinite(o.intervalMs) && o.intervalMs > 0) { + out.intervalMs = o.intervalMs; + } + if (typeof o.startupTimeoutMs === "number" && Number.isFinite(o.startupTimeoutMs) && o.startupTimeoutMs > 0) { + out.startupTimeoutMs = o.startupTimeoutMs; + } + return out; +} + +/** + * Injectable HTTP readiness probe seam. Returns the HTTP status code for a GET + * of `url` (resolving the connection within `timeoutMs`), or `0` when the + * endpoint cannot be reached (connection refused / abort / network error). Fully + * mocked in unit tests so no real socket is opened. + */ +export type HttpHealthProbe = (url: string, timeoutMs: number) => Promise; + +const defaultHttpProbe: HttpHealthProbe = async (url, timeoutMs) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs)); + try { + const res = await fetch(url, { method: "GET", signal: controller.signal }); + // Drain the body so the connection can be released/closed promptly. + try { + await res.arrayBuffer(); + } catch { + /* ignore body errors — only the status matters */ + } + return res.status; + } catch { + return 0; + } finally { + clearTimeout(timer); + } +}; + +// ── Id encoding (URL-safe, reversible) ─────────────────────────────────────── + +/** Encode `{packId,runtimeId}` to a single URL-safe, reversible API id. */ +export function encodePackRuntimeId(packId: string, runtimeId: string): string { + return `${encodeURIComponent(packId)}:${encodeURIComponent(runtimeId)}`; +} + +/** + * Namespace a GENERATED-secret / HOST-port persistence key by pack+runtime + * identity. Two unrelated pack runtimes that both declare a key like `WEB_PORT`, + * `PORT`, or `DB_PASSWORD` would otherwise collide on the raw manifest key in the + * shared global `SecretsStore` / port store and overwrite each other's persisted + * value. The RAW manifest key is still used for the rendered env-var NAME and for + * reading USER-CONFIGURED secrets (which are intentionally global/shared — a user + * configures one LLM key once across runtimes), so only the persisted storage + * slot for auto-generated secrets and allocated ports is namespaced. + */ +export function packRuntimePersistKey(packId: string, runtimeId: string, rawKey: string): string { + return `pack-runtime:${encodeURIComponent(packId)}:${encodeURIComponent(runtimeId)}:${rawKey}`; +} + +/** Reverse {@link encodePackRuntimeId}. Throws {@link PackRuntimeBadRequestError}. */ +export function decodePackRuntimeId(id: string): { packId: string; runtimeId: string } { + if (typeof id !== "string" || id.length === 0) { + throw new PackRuntimeBadRequestError("pack runtime id is required"); + } + const idx = id.indexOf(":"); + if (idx <= 0 || idx >= id.length - 1) { + throw new PackRuntimeBadRequestError(`malformed pack runtime id ${JSON.stringify(id)}`); + } + let packId: string; + let runtimeId: string; + try { + packId = decodeURIComponent(id.slice(0, idx)); + runtimeId = decodeURIComponent(id.slice(idx + 1)); + } catch { + throw new PackRuntimeBadRequestError(`malformed pack runtime id ${JSON.stringify(id)}`); + } + if (!packId || !runtimeId) { + throw new PackRuntimeBadRequestError(`malformed pack runtime id ${JSON.stringify(id)}`); + } + return { packId, runtimeId }; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const TAIL_DEFAULT = 200; +const TAIL_MAX = 5000; + +/** Sanitize a token for use inside a compose project name (`[a-z0-9_-]`). */ +export function sanitizeComposeToken(token: string): string { + const cleaned = String(token ?? "") + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/[-_]{2,}/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return cleaned.length > 0 ? cleaned.slice(0, 40) : "x"; +} + +/** True when an error is a missing-executable ENOENT (Docker not installed). */ +function isEnoent(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const e = err as { code?: unknown; message?: unknown }; + if (e.code === "ENOENT") return true; + return typeof e.message === "string" && e.message.includes("ENOENT"); +} + +/** Clamp/sanitize a requested log `tail` to a safe positive range. */ +export function clampTail(tail: unknown): number { + if (tail === undefined || tail === null) return TAIL_DEFAULT; + const n = typeof tail === "number" ? tail : Number(tail); + if (!Number.isFinite(n)) { + throw new PackRuntimeBadRequestError(`invalid tail ${JSON.stringify(tail)}`); + } + const int = Math.floor(n); + if (int < 1) return 1; + if (int > TAIL_MAX) return TAIL_MAX; + return int; +} + +interface RawComposePsRow { + [key: string]: unknown; +} + +/** Tolerantly parse `docker compose ps --format json` (array OR JSON-lines). */ +export function parseComposePs(stdout: string): PackRuntimeServiceStatus[] { + const text = (stdout ?? "").trim(); + if (!text) return []; + const rows: RawComposePsRow[] = []; + if (text.startsWith("[")) { + try { + const arr = JSON.parse(text); + if (Array.isArray(arr)) rows.push(...(arr as RawComposePsRow[])); + } catch { + /* fall through — nothing parseable */ + } + } else { + for (const line of text.split(/\r?\n/)) { + const l = line.trim(); + if (!l) continue; + try { + rows.push(JSON.parse(l) as RawComposePsRow); + } catch { + /* skip non-JSON noise lines */ + } + } + } + const out: PackRuntimeServiceStatus[] = []; + for (const row of rows) { + const name = + (typeof row.Service === "string" && row.Service) || + (typeof row.service === "string" && row.service) || + (typeof row.Name === "string" && row.Name) || + (typeof row.name === "string" && row.name) || + ""; + if (!name) continue; + const svc: PackRuntimeServiceStatus = { name }; + const state = row.State ?? row.state; + if (typeof state === "string" && state.length > 0) svc.state = state; + const health = row.Health ?? row.health; + if (typeof health === "string" && health.length > 0) svc.health = health; + out.push(svc); + } + return out; +} + +/** Map parsed compose service states to a runtime status state. */ +export function mapServicesToState(services: PackRuntimeServiceStatus[]): PackRuntimeStatusState { + if (services.length === 0) return "stopped"; + const norm = services.map((s) => ({ + state: (s.state ?? "").toLowerCase(), + health: (s.health ?? "").toLowerCase(), + })); + if (norm.some((s) => s.health === "unhealthy")) return "unhealthy"; + const isReady = (s: { state: string; health: string }) => + s.state === "running" && (s.health === "" || s.health === "healthy"); + if (norm.every(isReady)) return "running"; + if ( + norm.some( + (s) => + s.state === "running" || + s.state === "created" || + s.state === "restarting" || + s.health === "starting", + ) + ) { + return "starting"; + } + return "stopped"; +} + +const defaultSleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** + * The compose addressing context shared by every Docker command for a runtime: + * the collision-guarded project name plus the validated invocation's resolved + * `composeFile` and rendered `envFile`. Carrying `-f`/`--env-file` on EVERY + * call (not just `up`) means status/stop/logs inspect/control the correct + * compose context regardless of the gateway's cwd. + */ +interface ComposeTarget { + composeProject: string; + composeFile: string; + /** + * Rendered `.env` file to hand compose via `--env-file`. OPTIONAL: the + * read-only status/list path omits it when no env file has been rendered yet + * (a never-started runtime), so a fresh runtime can be inspected without first + * rendering an env file / resolving deployment secrets. Control paths + * (start/stop/logs/down) always carry a rendered env file. + */ + envFile?: string; +} + +// ── Supervisor ─────────────────────────────────────────────────────────────── + +export class PackRuntimeSupervisor { + private readonly registry: PackContributionResolver; + private readonly dockerBin: string; + private readonly executor: DockerExecutor; + private readonly httpProbe: HttpHealthProbe; + private readonly suffix: string; + private readonly startupTimeoutMs: number; + private readonly pollIntervalMs: number; + private readonly commandTimeoutMs: number; + private readonly runtimeDataDir: string; + private readonly now: () => number; + private readonly sleep: (ms: number) => Promise; + private readonly secretsStore?: SecretLike; + private readonly portStore?: PortStore; + private readonly buildContext?: ( + manifest: RuntimeManifest, + contribution: RuntimeContribution, + ) => RuntimeResolveContext | Promise; + + /** + * Dedupes concurrent `ensureRuntime`/`start` for one runtime IDENTITY (pack + + * runtime, mode-AGNOSTIC): while a start is in flight, later callers for the + * SAME mode await the same Promise (one `compose up`). On settle, the entry is + * cleared so a later call can retry. Mirrors `sandbox-manager.ts`'s + * `_ensureInFlight` discipline. + * + * The key deliberately OMITS projectId: the rendered `.env` file and compose + * project ({@link _envFilePath}/{@link composeProjectFor}) are keyed by pack + + * runtime + server suffix ONLY — they are NOT project-scoped. So two starts for + * the SAME pack/runtime from DIFFERENT project scopes target the SAME env file + * and SAME compose project; keying the in-flight slot by projectId would let + * them bypass this guard and race (two `compose up`s clobbering one env file). + * The key must therefore match the actual shared runtime identity. + * + * The key is mode-agnostic ON PURPOSE: a runtime identity owns ONE rendered + * `.env` file + ONE compose project, so two concurrent starts requesting + * DIFFERENT modes would race — both `renderRuntimeEnvFile` to the same path and + * `compose up` against the same project, the second clobbering the first's env. + * We record the in-flight mode alongside the promise and REJECT a concurrent + * start that requests a conflicting mode (rather than silently collapsing it + * onto the first mode's promise) so the race is impossible and the caller learns + * its mode was not honoured. + */ + private readonly _startInFlight = new Map; mode?: string }>(); + + /** + * Runtime keys (pack+runtime, mode-agnostic) this supervisor instance + * has successfully brought up to `running`. Drives the idempotent {@link start} + * fast-path: a repeat `start` of a still-running runtime must not re-render the + * invocation, `compose up` again, or rotate its host port. Cleared on + * {@link stop}/{@link down} (the runtime is no longer up). Per-instance (not + * disk-backed) so it never leaks across the unit fixtures' shared data dir. + */ + private readonly _started = new Set(); + + constructor(opts: PackRuntimeSupervisorOptions) { + this.registry = opts.registry; + this.dockerBin = opts.dockerBin ?? process.env.DOCKER_BIN ?? "docker"; + this.executor = opts.executor ?? (execFileAsync as unknown as DockerExecutor); + this.httpProbe = opts.httpProbe ?? defaultHttpProbe; + this.suffix = sanitizeComposeToken(opts.serverIdentitySuffix ?? crypto.randomBytes(4).toString("hex")); + this.startupTimeoutMs = opts.startupTimeoutMs ?? 60_000; + this.pollIntervalMs = opts.pollIntervalMs ?? 1_000; + this.commandTimeoutMs = opts.commandTimeoutMs ?? 120_000; + this.runtimeDataDir = opts.runtimeDataDir ?? path.join(os.tmpdir(), "bobbit-pack-runtimes"); + this.now = opts.now ?? Date.now; + this.sleep = opts.sleep ?? defaultSleep; + this.secretsStore = opts.secretsStore; + this.portStore = opts.portStore; + this.buildContext = opts.buildContext; + } + + /** Compose project name for a pack (collision-guarded by server suffix). */ + composeProjectFor(packId: string): string { + return `bobbit-pack-${sanitizeComposeToken(packId)}-${this.suffix}`; + } + + /** Status for every active pack runtime in a project scope. */ + async list(projectId?: string): Promise { + const out: PackRuntimeStatus[] = []; + for (const pack of this.registry.list(projectId)) { + for (const runtime of pack.runtimes) { + try { + out.push(await this.status(pack.packId, runtime.id, projectId)); + } catch (err) { + // Isolate a single unusable runtime (e.g. an invalid manifest) so it + // can't blank the entire boot listing. Surface it as a structured + // `stopped` row carrying the reason rather than throwing the whole + // list. This is a READ path — it never starts Docker or mutates state. + const descriptor = this._descriptor(runtime, pack.packId, runtime.id, pack.packName); + out.push({ + ...descriptor, + status: "stopped", + composeProject: this.composeProjectFor(pack.packId), + message: (err as Error)?.message ?? String(err), + }); + } + } + } + return out; + } + + /** + * Current status for a single runtime (Docker queried via `compose ps`). + * + * READ-ONLY: this path NEVER renders an env file, allocates/persists a host + * port, or resolves deployment secrets (e.g. HINDSIGHT_API_LLM_API_KEY). It + * derives a minimal, non-mutating compose target — the collision-guarded + * project name plus the contained compose file and this runtime's services — + * and reuses an already-rendered `.env` file ONLY when one exists from a prior + * start. So a fresh/default unstarted runtime reports `stopped` (or + * `docker-unavailable`) without requiring any deployment config, and polling + * status can never silently mutate runtime state. An invalid manifest still + * propagates (→ PackRuntimeBadRequestError → 400) before any Docker command. + */ + async status(packId: string, runtimeId: string, projectId?: string): Promise { + const { contribution, packName } = this._lookup(packId, runtimeId, projectId); + const descriptor = this._descriptor(contribution, packId, runtimeId, packName); + const { target, services } = this._readonlyComposeContext(packId, runtimeId, contribution); + return this._statusFromPs(descriptor, target, services); + } + + /** Idempotent start. Fast-paths when already running; dedupes concurrent starts. */ + async ensureRuntime( + packId: string, + runtimeId: string, + opts: { projectId?: string; mode?: string } = {}, + ): Promise { + const current = await this.status(packId, runtimeId, opts.projectId); + if (current.status === "running") return current; + // Preserve a clear docker-unavailable state — do not fall through to start + // (which would re-run Docker / render env and surface a noisier error). + if (current.status === "docker-unavailable") return current; + return this._startDeduped(packId, runtimeId, opts); + } + + /** Start a runtime (renders env, `compose up -d`, polls to healthy/timeout). */ + async start( + packId: string, + runtimeId: string, + opts: { projectId?: string; mode?: string; config?: Record } = {}, + ): Promise { + // Idempotent fast-path: a runtime THIS supervisor already brought up stays up. + // Re-running `start` must NOT re-render the invocation, rotate the (now + // container-bound) persisted host port — `allocateHostPort` would treat the + // bound port as unavailable, probe+persist a NEW one, and the next `compose up` + // would orphan the live port mapping — or `compose up` again. We short-circuit + // ONLY when THIS instance previously started the runtime to `running` AND it + // still reports `running`, so a fresh first start always proceeds to `compose + // up` and a stopped runtime (e.g. mid-`restart`, which clears the flag) is + // (re)started normally. As a second line of defence the start path also reuses + // any persisted host port verbatim (see `_doStart`'s `reusePersisted`), so a + // post-restart start of an already-running runtime never rotates the port even + // before this in-memory flag is repopulated. + if (this._started.has(this._startedKey(packId, runtimeId))) { + const current = await this.status(packId, runtimeId, opts.projectId); + if (current.status === "running") return current; + } + return this._startDeduped(packId, runtimeId, opts); + } + + /** + * Mode-agnostic key for the {@link _started} idempotence set. Keyed by + * pack+runtime ONLY (NOT projectId): the runtime's compose project + env file + * are not project-scoped, so the idempotence flag must track that same shared + * identity — else a start from project A wouldn't see a start from project B as + * already-running and would re-`compose up` the same project. + */ + private _startedKey(packId: string, runtimeId: string): string { + return `${packId}\u0000${runtimeId}`; + } + + /** Stop a runtime (`compose stop`) and report the resulting status. */ + async stop( + packId: string, + runtimeId: string, + opts: { projectId?: string } = {}, + ): Promise { + const { contribution, packName } = this._lookup(packId, runtimeId, opts.projectId); + const descriptor = this._descriptor(contribution, packId, runtimeId, packName); + // READ-ONLY compose target — like `down`/`logs`, `stop` never rebuilds a full + // start invocation. Rebuilding one resolves deployment secrets (e.g. the + // start-only HINDSIGHT_API_LLM_API_KEY) / renders a fresh env file, which + // would make disable/stop FAIL for a never-started or default managed runtime + // whose start-only inputs aren't configured yet. The minimal target carries + // the collision-guarded project + contained compose file + this runtime's + // owned services, reusing an already-rendered `.env` ONLY when a prior start + // left one. A manifest validation/containment failure still propagates (it + // never degrades to an unscoped whole-pack `stop`); the service list is empty + // ONLY for a valid manifest that truly declares no services. + const { target, services } = this._readonlyComposeContext(packId, runtimeId, contribution); + // The runtime is being brought down — drop the idempotent-start flag so a + // later `start` (incl. the `restart` stop→start) actually (re)starts it. + this._started.delete(this._startedKey(packId, runtimeId)); + try { + await this._exec(this._composeArgs(target, "stop", ...services)); + } catch (err) { + if (isEnoent(err)) { + return { ...descriptor, status: "docker-unavailable", composeProject: target.composeProject, message: "docker is not available" }; + } + throw err; + } + return this._statusFromPs(descriptor, target, services); + } + + /** Stop then start a runtime. */ + async restart( + packId: string, + runtimeId: string, + opts: { projectId?: string; mode?: string; config?: Record } = {}, + ): Promise { + await this.stop(packId, runtimeId, { projectId: opts.projectId }); + return this.start(packId, runtimeId, opts); + } + + /** Recent logs for a runtime (`compose logs --tail N`). */ + async logs( + packId: string, + runtimeId: string, + opts: { projectId?: string; tail?: number } = {}, + ): Promise { + // Validate the runtime exists (404 mapping) before touching Docker. + const { contribution } = this._lookup(packId, runtimeId, opts.projectId); + const tail = clampTail(opts.tail); + // READ-ONLY: `logs` neither consumes nor needs the resolved start env, so it + // uses the minimal read-only compose target (like `status`) rather than + // rebuilding a full start invocation. This keeps logs viewable for a managed + // runtime whose start-only secret (HINDSIGHT_API_LLM_API_KEY) lives only in + // deployment config, or one that was never started (no env file / sidecar), + // instead of failing the panel's logs affordance with a 400. Invalid manifests + // still propagate rather than degrading to an unscoped whole-pack `logs`. + const { target, services } = this._readonlyComposeContext(packId, runtimeId, contribution); + try { + const { stdout } = await this._exec( + this._composeArgs(target, "logs", "--tail", String(tail), ...services), + ); + return stdout; + } catch (err) { + // Surface a consistent docker-unavailable failure instead of hiding a + // missing-Docker install behind empty output (REST maps this to a + // docker-unavailable shaped response). + if (isEnoent(err)) throw new PackRuntimeDockerUnavailableError(); + throw err; + } + } + + /** + * Tear a runtime down (`docker compose down`). Unlike {@link stop} (which + * `compose stop`s the runtime's services but leaves the compose project, + * networks and ANONYMOUS volumes in place), `down` removes the project's + * containers + networks. It is the uninstall/purge primitive: + * + * - `volumes: false` (default) — `compose down`. Bind-mounted data (e.g. the + * managed Postgres data dir) is OUTSIDE compose-managed volumes and SURVIVES, + * so an uninstall→reinstall keeps the user's memory. This is the uninstall path. + * - `volumes: true` — `compose down -v`. Removes named/anonymous compose volumes + * too. The explicit PURGE path. + * - `removeState: true` — additionally delete supervisor-owned local runtime + * state (rendered env file + persisted generated secrets + allocated ports). + * Bind-mounted DATA is never deleted here. + * + * A missing Docker install surfaces as a `docker-unavailable` status (never a + * throw) so an uninstall on a Docker-less host still proceeds; local state + * removal still runs when requested. + * + * TEARDOWN MUST NOT REQUIRE START-ONLY INPUTS. `down` addresses the compose + * project with the READ-ONLY minimal target ({@link _readonlyComposeContext}) — + * the collision-guarded project name + the contained compose file, reusing an + * already-rendered `.env` ONLY when a prior start left one. It deliberately does + * NOT rebuild a full start invocation (which resolves deployment secrets / + * `requireEnv`), so a managed runtime whose config lacks `llmApiKey`, or one that + * was never started (no env file / config sidecar), can still be torn down. + */ + async down( + packId: string, + runtimeId: string, + opts: { projectId?: string; volumes?: boolean; removeState?: boolean } = {}, + ): Promise { + const { contribution, packName } = this._lookup(packId, runtimeId, opts.projectId); + const descriptor = this._descriptor(contribution, packId, runtimeId, packName); + const composeProject = this.composeProjectFor(packId); + // Tearing the project down — the runtime is no longer up; drop the flag. + this._started.delete(this._startedKey(packId, runtimeId)); + const { target } = this._readonlyComposeContext(packId, runtimeId, contribution); + const downArgs = this._composeArgs(target, "down", ...(opts.volumes ? ["-v"] : [])); + try { + await this._exec(downArgs); + } catch (err) { + if (isEnoent(err)) { + // Docker is unavailable — local state removal is still meaningful (the + // rendered env / persisted ports/secrets live on the host FS). + if (opts.removeState) this._removeRuntimeState(packId, runtimeId, contribution, composeProject); + return { ...descriptor, status: "docker-unavailable", composeProject, message: "docker is not available" }; + } + throw err; + } + if (opts.removeState) this._removeRuntimeState(packId, runtimeId, contribution, composeProject); + // `compose down` removed the project's containers — the runtime is stopped. + return { ...descriptor, status: "stopped", composeProject }; + } + + /** + * Pre-start consent disclosure (P3 §8). PURE w.r.t. Docker — derived only from + * the validated manifest, the selected mode, and any ALREADY-persisted host + * ports (never allocates). Safe to render before the user consents to a start. + */ + async capabilitySummary( + packId: string, + runtimeId: string, + opts: { projectId?: string; mode?: string; config?: Record } = {}, + ): Promise { + const { contribution, packName } = this._lookup(packId, runtimeId, opts.projectId); + const descriptor = this._descriptor(contribution, packId, runtimeId, packName); + const manifest = this._resolveManifest(contribution); + const modeKeys = Object.keys(manifest.modes ?? {}); + if (opts.mode !== undefined && !manifest.modes?.[opts.mode]) { + throw new PackRuntimeBadRequestError(`runtime ${contribution.id} has no mode ${JSON.stringify(opts.mode)}`); + } + const modeKey = opts.mode ?? modeKeys[0]; + if (!modeKey) { + throw new PackRuntimeBadRequestError(`runtime ${contribution.id} declares no modes`); + } + const modeSpec = manifest.modes![modeKey]; + const omit = new Set(modeSpec.omitServices ?? []); + const services = (modeSpec.services ?? []).filter((s) => !omit.has(s)); + const ports: PackRuntimeCapabilityPort[] = (manifest.ports ?? []).map((spec) => { + const p: PackRuntimeCapabilityPort = { key: spec.key }; + if (spec.env) p.env = spec.env; + if (typeof spec.container === "number") p.container = spec.container; + // Disclose the persisted host port WITHOUT allocating a new one. + const host = this.portStore?.get(packRuntimePersistKey(packId, runtimeId, spec.key)); + if (typeof host === "number" && Number.isInteger(host)) p.host = host; + return p; + }); + const volumePath = this._resolveVolumePath(manifest, modeSpec, opts.config); + return { + ...descriptor, + mode: modeKey, + startPolicy: readRuntimeStartPolicy(contribution.manifest), + composeProject: this.composeProjectFor(packId), + services, + images: [...services], + ports, + ...(volumePath ? { volumePath } : {}), + trust: + "Enabling this managed runtime lets Bobbit store and recall agent memory " + + "(conversation summaries and project/goal/session tags) in the configured bank. " + + "Disabling stops the containers but keeps the data on disk; purge removes Docker " + + "volumes and supervisor-owned runtime state.", + }; + } + + /** The runtime's declared start policy (defaults to `manual`). No Docker. */ + startPolicyFor(packId: string, runtimeId: string, projectId?: string): PackRuntimeStartPolicy { + const { contribution } = this._lookup(packId, runtimeId, projectId); + return readRuntimeStartPolicy(contribution.manifest); + } + + // ── internals ──────────────────────────────────────────────────────────── + + /** + * Best-effort resolution of the managed data/volume path for the consent + * disclosure. Scans the merged (manifest + mode) env for a literal `value` ref + * whose env NAME ends in `_DATA_DIR`, substituting any config-overlay vars so a + * `${dataDir:-~/.hindsight}` default resolves. Falls back to a configured + * `dataDir`. PURE. + */ + private _resolveVolumePath( + manifest: RuntimeManifest, + modeSpec: { env?: Record }, + configOverlay?: Record, + ): string | undefined { + const vars: Record = {}; + if (configOverlay) { + for (const [k, v] of Object.entries(configOverlay)) { + if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") vars[k] = String(v); + } + } + const merged: Record = { ...(manifest.env ?? {}), ...(modeSpec.env ?? {}) }; + for (const [name, value] of Object.entries(merged)) { + if (!/_DATA_DIR$/.test(name)) continue; + const raw = + typeof value === "string" + ? value + : value && typeof value === "object" && typeof (value as { value?: unknown }).value === "string" + ? (value as { value: string }).value + : undefined; + if (raw === undefined) continue; + const resolved = substitutePlaceholders(raw, vars); + if (resolved) return resolved; + } + const dataDir = configOverlay?.dataDir; + return typeof dataDir === "string" && dataDir.length > 0 ? dataDir : undefined; + } + + /** + * Delete supervisor-owned LOCAL runtime state for a purge: the rendered env + * file (and the compose-project env dir when it becomes empty), persisted + * generated secrets, and allocated host ports — all namespaced by + * {@link packRuntimePersistKey}. Bind-mounted DATA (e.g. HINDSIGHT_DATA_DIR) is + * NEVER touched here; only the supervisor's own bookkeeping. Best-effort. + */ + private _removeRuntimeState( + packId: string, + runtimeId: string, + contribution: RuntimeContribution, + composeProject: string, + ): void { + // 1. Rendered .env file + persisted config sidecar + the (now-empty) dir. + try { + fs.rmSync(this._envFilePath(composeProject, runtimeId), { force: true }); + } catch { + /* best effort */ + } + try { + fs.rmSync(this._runtimeConfigPath(composeProject, runtimeId), { force: true }); + } catch { + /* best effort */ + } + try { + const dir = path.join(this.runtimeDataDir, composeProject); + if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); + } catch { + /* best effort */ + } + + // 2. Persisted generated secrets + allocated ports. Compute the keys from the + // manifest (same collection logic as `_resolveContext`) and drop their + // namespaced persistence slots. Structural `remove` calls keep us decoupled + // from the concrete SecretsStore / FilePortStore types. + let manifest: RuntimeManifest | null = null; + try { + manifest = this._resolveManifest(contribution); + } catch { + manifest = null; + } + if (!manifest) return; + const generatedKeys = new Set(); + const portKeys = new Set(); + for (const spec of manifest.secrets ?? []) { + if (spec.generate) generatedKeys.add(spec.key); + } + for (const spec of manifest.ports ?? []) portKeys.add(spec.key); + const envMaps = [manifest.env, ...Object.values(manifest.modes ?? {}).map((m) => m.env)]; + for (const env of envMaps) { + for (const value of Object.values(env ?? {})) { + if (!value || typeof value !== "object") continue; + if (typeof value.generate === "string") generatedKeys.add(value.generate); + if (typeof value.port === "string") portKeys.add(value.port); + } + } + const secretsRemover = this.secretsStore as unknown as { remove?: (key: string) => void } | undefined; + for (const key of generatedKeys) { + try { + secretsRemover?.remove?.(packRuntimePersistKey(packId, runtimeId, key)); + } catch { + /* best effort */ + } + } + const portRemover = this.portStore as unknown as { remove?: (key: string) => void } | undefined; + for (const key of portKeys) { + try { + portRemover?.remove?.(packRuntimePersistKey(packId, runtimeId, key)); + } catch { + /* best effort */ + } + } + } + + /** + * Dedupe key for an in-flight start — the runtime IDENTITY only (pack + + * runtime), mode-AGNOSTIC and project-AGNOSTIC. A runtime identity owns ONE + * rendered env file + ONE compose project (NEITHER is project-scoped), so all + * starts for it — from ANY project scope — must serialize through one in-flight + * slot regardless of mode (see {@link _startInFlight}). The requested mode is + * tracked separately and a conflicting concurrent mode is rejected, not keyed + * apart. Including projectId here would reopen the cross-project start race. + */ + private _runtimeKey(packId: string, runtimeId: string): string { + return `${packId}\u0000${runtimeId}`; + } + + private _startDeduped( + packId: string, + runtimeId: string, + opts: { projectId?: string; mode?: string; config?: Record }, + ): Promise { + const key = this._runtimeKey(packId, runtimeId); + const inFlight = this._startInFlight.get(key); + if (inFlight) { + // A start for this runtime identity is already in flight. Same mode ⇒ share + // its promise (one `compose up`). DIFFERENT mode ⇒ reject: honouring it would + // race the first start on the SAME env file + compose project. The caller + // should retry after the in-flight start settles. + if ((inFlight.mode ?? "") === (opts.mode ?? "")) return inFlight.promise; + return Promise.reject( + new PackRuntimeBadRequestError( + `runtime ${packId}:${runtimeId} already starting in mode ${JSON.stringify(inFlight.mode ?? "")}; ` + + `cannot concurrently start it in mode ${JSON.stringify(opts.mode ?? "")}`, + ), + ); + } + const p = this._doStart(packId, runtimeId, opts); + this._startInFlight.set(key, { promise: p, mode: opts.mode }); + const cleanup = () => { + if (this._startInFlight.get(key)?.promise === p) this._startInFlight.delete(key); + }; + p.then(cleanup, cleanup); + return p; + } + + private async _doStart( + packId: string, + runtimeId: string, + opts: { projectId?: string; mode?: string; config?: Record }, + ): Promise { + const { contribution, packName } = this._lookup(packId, runtimeId, opts.projectId); + const descriptor = this._descriptor(contribution, packId, runtimeId, packName); + const composeProject = this.composeProjectFor(packId); + const envFile = this._envFilePath(composeProject, runtimeId); + + // `reusePersisted: true` — a valid persisted host port is reused VERBATIM + // (no bindability re-probe), so a start of an already-running runtime (whose + // port is currently container-bound) never rotates it. Fresh runtimes with no + // persisted port still allocate one. This backstops the in-memory idempotent + // fast-path for the post-restart case (flag not yet repopulated). + const { invocation, modeKey, ctx } = await this._buildInvocation(packId, runtimeId, contribution, envFile, opts.mode, { + configOverlay: opts.config, + reusePersisted: true, + }); + + // Resolve the declared HTTP readiness probe (if any) + the resolved host + // port it targets, so `_pollUntilHealthy` can gate `running` on HTTP 200 + // rather than trusting a compose-ps "running". + const healthcheck = readRuntimeHealthcheck(contribution.manifest); + const healthPort = healthcheck ? ctx.ports?.[healthcheck.port] : undefined; + + renderRuntimeEnvFile(invocation.envFile, invocation.env); + // Record the effective mode + config overlay used for THIS start beside the + // rendered env file. Read/control commands (status/stop/logs/down) reuse the + // rendered `.env` file itself (config-only secrets/placeholders — an LLM key or + // external DB URL supplied via deployment config — are already baked into it), + // so they never re-resolve start-only inputs on teardown. The persisted config + // is diagnostic state the purge path ({@link _removeRuntimeState}) cleans up. + this._persistRuntimeConfig(composeProject, runtimeId, modeKey, opts.config); + + const target: ComposeTarget = { + composeProject, + composeFile: invocation.composeFile, + envFile: invocation.envFile, + }; + const upArgs = this._composeArgs( + target, + ...invocation.profiles.flatMap((p) => ["--profile", p]), + "up", + "-d", + ...invocation.services, + ); + + try { + await this._exec(upArgs); + } catch (err) { + if (isEnoent(err)) { + return { + ...descriptor, + status: "docker-unavailable", + mode: modeKey, + composeProject, + message: "docker is not available", + }; + } + throw err; + } + + const result = await this._pollUntilHealthy(descriptor, target, modeKey, invocation.services, healthcheck, healthPort); + // Record a confirmed-running start so a later repeat `start` fast-paths instead + // of re-rendering / `compose up` / rotating the port. Only `running` qualifies — + // an `unhealthy`/timeout start stays restartable. + if (result.status === "running") { + this._started.add(this._startedKey(packId, runtimeId)); + } + return result; + } + + /** + * Poll until the runtime is ready (returns `running`) or the startup deadline + * elapses (returns `unhealthy`). Two readiness regimes: + * + * - HTTP-gated (a {@link RuntimeHealthcheck} is declared AND its host port + * resolved): `running` is reported ONLY once an HTTP GET of the declared + * health path at `http://127.0.0.1:` returns 200. A + * compose-ps "running" alone is deliberately NOT sufficient — the container + * can be up well before its HTTP server accepts requests. + * - compose-ps-only (no usable healthcheck): legacy behaviour — `running` + * once compose ps reports all services running/healthy. + * + * In both regimes a compose-ps `docker-unavailable` short-circuits, and a + * container reported `unhealthy` by Docker short-circuits to `unhealthy`. + */ + private async _pollUntilHealthy( + descriptor: PackRuntimeDescriptor, + target: ComposeTarget, + mode: string, + services: string[], + healthcheck?: RuntimeHealthcheck | null, + healthPort?: number, + ): Promise { + const httpGated = !!( + healthcheck && + typeof healthPort === "number" && + Number.isInteger(healthPort) && + healthPort >= 1 && + healthPort <= 65535 + ); + // The manifest healthcheck owns the loop timing when it declares it: a runtime + // that knows its own startup budget (e.g. Hindsight sizes + // `startupTimeoutMs: 120000` for image pull + DB init) and re-poll cadence + // (`intervalMs`) is honored as parsed by readRuntimeHealthcheck() (which only + // surfaces positive, finite values). The supervisor's injectable defaults are + // the fallback when the manifest omits/has an invalid value — and remain the + // sole governor for the compose-ps-only regime (no declared healthcheck). + const timeoutMs = healthcheck?.startupTimeoutMs ?? this.startupTimeoutMs; + const intervalMs = healthcheck?.intervalMs ?? this.pollIntervalMs; + const healthUrl = httpGated ? `http://127.0.0.1:${healthPort}${healthcheck!.path}` : ""; + const deadline = this.now() + timeoutMs; + for (;;) { + const status = await this._statusFromPs(descriptor, target, services, mode); + // A missing Docker install or a Docker-reported unhealthy container is + // terminal in both readiness regimes. + if (status.status === "docker-unavailable" || status.status === "unhealthy") { + return status; + } + if (httpGated) { + let code = 0; + try { + code = await this.httpProbe(healthUrl, intervalMs); + } catch { + code = 0; + } + if (code === 200) { + // HTTP health passed — NOW the runtime is genuinely ready. + return { ...status, status: "running", message: undefined }; + } + // else: ignore any compose-ps "running" and keep polling the HTTP path. + } else if (status.status === "running") { + return status; + } + if (this.now() >= deadline) { + return { + ...descriptor, + status: "unhealthy", + mode, + composeProject: target.composeProject, + services: status.services, + message: `runtime did not become healthy within ${timeoutMs}ms`, + }; + } + await this.sleep(intervalMs); + } + } + + private async _statusFromPs( + descriptor: PackRuntimeDescriptor, + target: ComposeTarget, + scopeServices: string[], + mode?: string, + ): Promise { + try { + // Scope `ps` to this runtime's services so status never reflects (or maps + // the health of) sibling runtimes sharing the pack compose project. The + // `-f composeFile`/`--env-file` come from the validated invocation so the + // inspected compose context is correct regardless of the gateway cwd. + const { stdout } = await this._exec( + this._composeArgs(target, "ps", "--format", "json", ...scopeServices), + ); + const services = parseComposePs(stdout); + return { + ...descriptor, + status: mapServicesToState(services), + mode, + composeProject: target.composeProject, + services, + }; + } catch (err) { + if (isEnoent(err)) { + return { + ...descriptor, + status: "docker-unavailable", + mode, + composeProject: target.composeProject, + message: "docker is not available", + }; + } + throw err; + } + } + + private _lookup( + packId: string, + runtimeId: string, + projectId?: string, + ): { contribution: RuntimeContribution; packName?: string } { + const contribution = this.registry.getRuntime(projectId, packId, runtimeId); + if (!contribution) { + throw new PackRuntimeNotFoundError(`unknown pack runtime ${packId}:${runtimeId}`); + } + const packName = this.registry.getPack(projectId, packId)?.packName; + return { contribution, packName }; + } + + private _descriptor( + contribution: RuntimeContribution, + packId: string, + runtimeId: string, + packName?: string, + ): PackRuntimeDescriptor { + const d: PackRuntimeDescriptor = { + id: encodePackRuntimeId(packId, runtimeId), + packId, + runtimeId, + }; + if (packName) d.packName = packName; + if (contribution.title) d.title = contribution.title; + if (contribution.description) d.description = contribution.description; + return d; + } + + /** + * Validate the carried manifest (deep, P1 semantics). Throws + * {@link PackRuntimeBadRequestError} for an unusable manifest. + */ + private _resolveManifest(contribution: RuntimeContribution): RuntimeManifest { + const problems: string[] = []; + const manifest = validateRuntimeManifest( + contribution.manifest, + contribution.sourceFile, + contribution.packRoot, + problems, + ); + if (!manifest) { + throw new PackRuntimeBadRequestError( + `invalid runtime manifest for ${contribution.id}: ${problems.join("; ") || "unknown error"}`, + ); + } + return manifest; + } + + /** + * The set of compose services this runtime owns — the union of every mode's + * declared `services` (a mode's `omitServices` only changes what `up` starts, + * not what the runtime manages). Used to scope `ps`/`stop`/`logs` so one + * runtime cannot read or stop a sibling runtime's services in a shared + * pack-scoped compose project. + * + * The empty (project-wide) list is reserved EXCLUSIVELY for a successfully + * validated manifest that genuinely declares no services. Manifest validation + * failures must NEVER reach here as `[]` — they propagate from + * {@link _resolveManifest} (callers go through {@link _readonlyComposeContext}), + * so an invalid/uncontained manifest can never silently broaden a `stop`/`logs` + * to the whole pack project. + */ + private _servicesForManifest(manifest: RuntimeManifest): string[] { + const set = new Set(); + for (const mode of Object.values(manifest.modes ?? {})) { + for (const svc of mode.services ?? []) set.add(svc); + } + return [...set]; + } + + /** Rendered `.env` file path for a runtime (one dir per compose project). */ + private _envFilePath(composeProject: string, runtimeId: string): string { + return path.join(this.runtimeDataDir, composeProject, `${sanitizeComposeToken(runtimeId)}.env`); + } + + /** + * Sidecar path persisting the effective mode + config overlay used at start. + * Lives beside the rendered `.env` file (one per compose project) and is + * removed by the purge path ({@link _removeRuntimeState}). + */ + private _runtimeConfigPath(composeProject: string, runtimeId: string): string { + return path.join(this.runtimeDataDir, composeProject, `${sanitizeComposeToken(runtimeId)}.config.json`); + } + + /** + * Persist the effective start `mode` + config overlay (0600, same posture as the + * rendered env file) so later read/control commands rebuild the SAME compose env. + * Best-effort: a write failure degrades to the prior no-overlay behaviour rather + * than failing the start. + */ + private _persistRuntimeConfig( + composeProject: string, + runtimeId: string, + mode: string, + config?: Record, + ): void { + try { + const file = this._runtimeConfigPath(composeProject, runtimeId); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify({ mode, config: config ?? {} })}\n`, { mode: 0o600 }); + fs.chmodSync(file, 0o600); + } catch { + /* best effort — control commands fall back to no overlay */ + } + } + + /** + * Base `docker compose` args carrying the project + `-f composeFile`, plus + * `--env-file` ONLY when the target has a rendered env file. The read-only + * status/list path omits it for a never-started runtime so compose is never + * handed a non-existent `--env-file` (which real Docker rejects) and so status + * never forces an env render just to inspect a dormant runtime. Control paths + * always pass a rendered env file. + */ + private _composeArgs(target: ComposeTarget, ...rest: string[]): string[] { + const base = ["compose", "-p", target.composeProject, "-f", target.composeFile]; + if (target.envFile) base.push("--env-file", target.envFile); + return [...base, ...rest]; + } + + /** + * Build a READ-ONLY compose target for {@link status}/{@link list}. Resolves + * ONLY the validated manifest (so an invalid/uncontained manifest still + * propagates → 400) and the contained compose path + this runtime's owned + * services. It NEVER renders an env file, allocates/persists a port, or + * resolves deployment secrets. A `.env` file rendered by a prior start is + * reused (accurate interpolation); otherwise `--env-file` is omitted entirely. + */ + private _readonlyComposeContext( + packId: string, + runtimeId: string, + contribution: RuntimeContribution, + ): { target: ComposeTarget; services: string[] } { + const manifest = this._resolveManifest(contribution); + const composeFile = resolveContainedComposePath( + manifest.composeFile, + contribution.sourceFile, + contribution.packRoot, + ); + if (composeFile === null) { + // Defensive: _resolveManifest already rejects an escaping composeFile. + throw new PackRuntimeBadRequestError(`runtime ${contribution.id} composeFile escapes the pack root`); + } + const composeProject = this.composeProjectFor(packId); + const envFile = this._envFilePath(composeProject, runtimeId); + const persistedEnv = fs.existsSync(envFile) ? envFile : undefined; + return { + target: { composeProject, composeFile, ...(persistedEnv ? { envFile: persistedEnv } : {}) }, + services: this._servicesForManifest(manifest), + }; + } + + /** + * Build a production-safe resolver context for a manifest's env refs. The + * injected {@link PackRuntimeSupervisorOptions.buildContext} wins when set; + * otherwise generated secrets are idempotently created+persisted, declared + * ports are allocated+persisted, and user-configured secrets are read from + * the secret store. This prevents real runtimes (e.g. Hindsight) from + * throwing in `buildRuntimeInvocation` before Docker is ever invoked. + */ + private async _resolveContext( + packId: string, + runtimeId: string, + manifest: RuntimeManifest, + contribution: RuntimeContribution, + opts: { reusePersisted?: boolean; configOverlay?: Record } = {}, + ): Promise { + if (this.buildContext) return this.buildContext(manifest, contribution); + + // Collect every key the manifest references, from BOTH the explicit + // secrets[]/ports[] declarations AND any env `secret|generate|port` refs + // (the LLM-key style user secret is declared only as an env `secret:` ref, + // not in secrets[]). Generated secrets win over user-configured for a key. + const generatedKeys = new Set(); + const userSecretKeys = new Set(); + const portKeys = new Set(); + for (const spec of manifest.secrets ?? []) { + (spec.generate ? generatedKeys : userSecretKeys).add(spec.key); + } + for (const spec of manifest.ports ?? []) portKeys.add(spec.key); + const envMaps = [manifest.env, ...Object.values(manifest.modes ?? {}).map((m) => m.env)]; + for (const env of envMaps) { + for (const value of Object.values(env ?? {})) { + if (!value || typeof value !== "object") continue; + if (typeof value.generate === "string") generatedKeys.add(value.generate); + if (typeof value.secret === "string") userSecretKeys.add(value.secret); + if (typeof value.port === "string") portKeys.add(value.port); + } + } + + const secrets: Record = {}; + const generated: Record = {}; + const ports: Record = {}; + // Generated secrets + allocated ports persist under a pack/runtime-namespaced + // store key (collision guard); the returned context maps stay keyed by the RAW + // manifest key so env refs still resolve by their declared name. User-configured + // secrets are read by their RAW key (intentionally global/shared). + for (const key of generatedKeys) { + generated[key] = this.secretsStore + ? getOrCreateRuntimeSecret(this.secretsStore, packRuntimePersistKey(packId, runtimeId, key)) + : generateSecretValue(); + } + for (const key of userSecretKeys) { + if (generatedKeys.has(key)) continue; + const v = this.secretsStore?.get(key); + if (typeof v === "string" && v.length > 0) secrets[key] = v; + } + for (const key of portKeys) { + if (!this.portStore) { + ports[key] = await probeFreePort(); + continue; + } + const storeKey = packRuntimePersistKey(packId, runtimeId, key); + if (opts.reusePersisted) { + // Reuse the persisted assignment verbatim (no bindability probe) so a + // running runtime's bound port is never rotated by a read/control call. + // Only when nothing valid is persisted do we allocate one. + const existing = this.portStore.get(storeKey); + if (typeof existing === "number" && Number.isInteger(existing) && existing >= 1 && existing <= 65535) { + ports[key] = existing; + continue; + } + } + ports[key] = await allocateHostPort(this.portStore, storeKey); + } + + // Configuration overlay (P3): the effective pack/provider config (e.g. the + // Hindsight deployment config — dataDir, externalDatabaseUrl, …) is mapped + // GENERICALLY onto the resolve context. Every scalar config entry is exposed + // as a placeholder var under its own key (so a literal env `value` ref like + // `${dataDir:-~/.hindsight}` resolves), AND, when a USER-configured secret + // ref's key is unresolved by the secret store, a config value of the same + // key fills it (so `secret: HINDSIGHT_API_DATABASE_URL` can be satisfied from + // config without persisting it to the global secret store). Generated secrets + // and allocated ports are never overridden by config. + const vars: Record = {}; + if (opts.configOverlay) { + for (const [k, v] of Object.entries(opts.configOverlay)) { + if (v === undefined || v === null) continue; + if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") { + vars[k] = String(v); + } + } + for (const key of userSecretKeys) { + if (generatedKeys.has(key)) continue; + if (secrets[key] !== undefined) continue; + const v = opts.configOverlay[key]; + if (typeof v === "string" && v.length > 0) secrets[key] = v; + } + } + return { secrets, generated, ports, vars }; + } + + private async _buildInvocation( + packId: string, + runtimeId: string, + contribution: RuntimeContribution, + envFile: string, + mode?: string, + opts: { reusePersisted?: boolean; configOverlay?: Record } = {}, + ): Promise<{ manifest: RuntimeManifest; modeKey: string; invocation: RuntimeInvocation; ctx: RuntimeResolveContext }> { + const manifest = this._resolveManifest(contribution); + const modeKeys = Object.keys(manifest.modes ?? {}); + if (mode !== undefined && !manifest.modes?.[mode]) { + throw new PackRuntimeBadRequestError(`runtime ${contribution.id} has no mode ${JSON.stringify(mode)}`); + } + const modeKey = mode ?? modeKeys[0]; + if (!modeKey) { + throw new PackRuntimeBadRequestError(`runtime ${contribution.id} declares no modes`); + } + const ctx = await this._resolveContext(packId, runtimeId, manifest, contribution, opts); + let invocation: RuntimeInvocation; + try { + invocation = buildRuntimeInvocation(manifest, modeKey, { + sourceFile: contribution.sourceFile, + packRoot: contribution.packRoot, + envFile, + ctx, + }); + } catch (err) { + // `buildRuntimeInvocation` rejects config/user errors (unresolved env refs, + // unmet requireEnv, compose-path containment, …) as plain `Error`. These are + // client/config faults, not server faults — surface them as a bad-request so + // the REST layer answers 400 instead of a misleading 500. Already-typed + // supervisor errors propagate unchanged. + if ( + err instanceof PackRuntimeBadRequestError || + err instanceof PackRuntimeNotFoundError || + err instanceof PackRuntimeDockerUnavailableError + ) { + throw err; + } + throw new PackRuntimeBadRequestError( + `runtime ${contribution.id} invocation failed: ${(err as Error)?.message ?? String(err)}`, + ); + } + return { manifest, modeKey, invocation, ctx }; + } + + private _exec(args: readonly string[]): Promise { + return this.executor(this.dockerBin, args, { + env: { ...process.env, MSYS_NO_PATHCONV: "1", MSYS2_ARG_CONV_EXCL: "*" }, + timeout: this.commandTimeoutMs, + windowsHide: true, + maxBuffer: 16 * 1024 * 1024, + encoding: "utf-8", + }); + } +} diff --git a/src/server/server.ts b/src/server/server.ts index 276423f15..8bcc2a9db 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -53,8 +53,21 @@ import { resolvePackIdentityForTool } from "./extension-host/pack-identity.js"; import { mintSurfaceToken, resolveSurfaceIdentity } from "./extension-host/surface-binding.js"; import type { StorePutOptions } from "../shared/extension-host/host-api.js"; import { PackContributionRegistry } from "./extension-host/pack-contribution-registry.js"; -import { loadPackContributions, providerConfigStoreKey, PROVIDER_CONFIG_KEY_PREFIX } from "./agent/pack-contributions.js"; -import { LifecycleHub, type HookCtx } from "./agent/lifecycle-hub.js"; +import { + PackRuntimeSupervisor, + FilePortStore, + getOrCreatePackRuntimeServerIdentity, + encodePackRuntimeId, + decodePackRuntimeId, + PackRuntimeNotFoundError, + PackRuntimeBadRequestError, + PackRuntimeDockerUnavailableError, + readRuntimeStartPolicy, + type PackRuntimeStatus, + type PackRuntimeCapabilitySummary, +} from "./runtimes/index.js"; +import { loadPackContributions, packIdFromRoot, providerConfigStoreKey, PROVIDER_CONFIG_KEY_PREFIX } from "./agent/pack-contributions.js"; +import { LifecycleHub, type HookCtx, type RuntimeContext } from "./agent/lifecycle-hub.js"; import { ContextTraceStore } from "./agent/context-trace-store.js"; import { fenceBlock } from "./agent/context-blocks.js"; import { isPackPathWithinRoot } from "./extension-host/path-guard.js"; @@ -288,7 +301,8 @@ import { InboxManager, type InboxEntry } from "./agent/inbox-manager.js"; import { InboxNudger } from "./agent/inbox-nudger.js"; import type { InboxStore } from "./agent/inbox-store.js"; import { PreferencesStore } from "./agent/preferences-store.js"; -import { ProjectConfigStore, type PackOrderScope } from "./agent/project-config-store.js"; +import { ProjectConfigStore, type PackOrderScope, type DisabledRefs } from "./agent/project-config-store.js"; +import { resolveDefaultActivationOverlay, buildAllDisabledRefs, isProviderConfigConfigured } from "./agent/pack-default-activation.js"; import { ToolGroupPolicyStore } from "./agent/tool-group-policy-store.js"; import { getAllConfigDirectories, removeBuiltinDirectory, resetConfigDirectories } from "./agent/config-directories.js"; import { checkDockerAvailability, buildSandboxImage, isBuildingImage, ensureImageAgentVersion, resolveSandboxDockerContext } from "./agent/sandbox-status.js"; @@ -425,6 +439,84 @@ export function readConcretePackToolsFromGroups( return { tools, descriptions }; } +// ── Default-disabled built-in packs (e.g. Hindsight) ───────────────────────── +// A built-in first-party pack may ship `defaultDisabled: true` in its manifest: +// it lists in the Marketplace built-in band but resolves DORMANT (tools, +// provider, entrypoints, runtime all absent) on a fresh server until the user +// enables it OR it is "already configured" (live-setup preservation). The +// overlay is injected into the SERVER-scope ProjectConfigStore so the single +// getPackActivation seam (cascade, registry, tool-manager, slash-skills, +// Marketplace endpoints) all observe the same effective state; it is never +// persisted, so the dormancy invariant holds and an explicit user toggle (a +// persisted record, or the force-enabled marker) always wins. +// +// These helpers are MODULE-scoped (not closed over createGateway) so the +// activation PUT inside the top-level handleApiRoute shares them. The static +// per-pack info is memoized and cleared on any pack-list mutation via +// clearDefaultDisabledInfoCache(); the live gates (force-enabled marker + +// persisted provider config) are read each call. +interface DefaultDisabledInfo { allDisabled: DisabledRefs; packId: string; providerIds: string[] } +const defaultDisabledInfoCache = new Map(); +function clearDefaultDisabledInfoCache(): void { defaultDisabledInfoCache.clear(); } +/** Resolve + memoize the default-disabled info for a SERVER-scope pack name, or + * `null` when the pack is not default-disabled. An installed server market pack + * wins over the built-in band (mirrors buildActivationCatalogue's resolution). */ +function getDefaultDisabledInfo(packName: string, serverStore: ProjectConfigStore): DefaultDisabledInfo | null { + const cached = defaultDisabledInfoCache.get(packName); + if (cached !== undefined) return cached; + let info: DefaultDisabledInfo | null = null; + const base = getProjectRoot(); + let entry = scopeMarketPackEntries("server" as PackScope, base, serverStore.getPackOrder("server")) + .find((e) => e.manifest?.name === packName); + if (!entry || !entry.manifest) { + entry = builtinFirstPartyPackEntries(resolveBuiltinPacksDir()).find((e) => e.manifest?.name === packName); + } + if (entry?.manifest?.defaultDisabled === true) { + const concrete = readConcretePackToolsFromGroups(entry.path, entry.manifest.contents.tools).tools; + let providerIds: string[] = []; + try { providerIds = loadPackContributions(entry.path, entry.manifest).providers.map((p) => p.id); } + catch { /* contributions optional; configured-check just sees no providers */ } + info = { + allDisabled: buildAllDisabledRefs(entry.manifest, concrete), + packId: packIdFromRoot(entry.path), + providerIds, + }; + } + defaultDisabledInfoCache.set(packName, info); + return info; +} +/** Persisted marker key: pack names the user EXPLICITLY enabled. An explicit + * enable clears all disabled refs (an empty record, indistinguishable from + * "never touched"), so the marker disambiguates it from the default-disabled + * baseline and makes the enable survive reboots. */ +const PACK_FORCE_ENABLED_KEY = "pack_force_enabled"; +/** Order-insensitive equality for two DisabledRefs (kinds present with non-empty + * arrays must match as SETS). Used to detect when an activation PUT matches the + * pack's current default so we persist a deviation only. */ +function disabledRefsEqual(a: DisabledRefs, b: DisabledRefs): boolean { + const kindsOf = (r: DisabledRefs): string[] => + Object.keys(r).filter((k) => Array.isArray((r as Record)[k]) && (r as Record)[k].length > 0).sort(); + const ka = kindsOf(a); const kb = kindsOf(b); + if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false; + for (const k of ka) { + const sa = [...(a as Record)[k]].sort(); + const sb = [...(b as Record)[k]].sort(); + if (sa.length !== sb.length || sa.some((v, i) => v !== sb[i])) return false; + } + return true; +} +function readForceEnabledPacks(store: ProjectConfigStore): Set { + try { + const raw = store.get(PACK_FORCE_ENABLED_KEY); + const arr = raw ? (JSON.parse(raw) as unknown) : []; + return new Set(Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : []); + } catch { return new Set(); } +} +function writeForceEnabledPacks(store: ProjectConfigStore, set: Set): void { + if (set.size === 0) store.remove(PACK_FORCE_ENABLED_KEY); + else store.set(PACK_FORCE_ENABLED_KEY, JSON.stringify([...set].sort())); +} + export function buildMarketToolRootsForProject(options: { projectId?: string; builtinEntries: readonly PackEntry[]; @@ -965,6 +1057,152 @@ export interface GatewayConfig { forceAuth?: boolean; } +// ─────────────────────────────────────────────────────────────────────────── +// Pack managed-runtime supervisor seam (P2 — see docs design "P2 +// PackRuntimeSupervisor + REST"). The concrete Docker-backed supervisor + its +// id encode/decode helpers + error classes live in ./runtimes (imported above). +// The REST routes depend on the structural seam below so test harnesses can +// inject a fully-mocked supervisor (no Docker daemon) via +// registerPackRuntimeSupervisorFactory(); production builds the real +// PackRuntimeSupervisor in start(). + +/** The structural contract the REST routes depend on (the concrete + * PackRuntimeSupervisor implements a superset). Unknown-runtime failures surface + * as {@link PackRuntimeNotFoundError} (→ 404); malformed id/mode/tail as + * {@link PackRuntimeBadRequestError} (→ 400); anything else → 500. */ +export interface PackRuntimeSupervisorLike { + list(projectId?: string): Promise; + status(packId: string, runtimeId: string, projectId?: string): Promise; + start(packId: string, runtimeId: string, opts?: { projectId?: string; mode?: string; config?: Record }): Promise; + stop(packId: string, runtimeId: string, opts?: { projectId?: string }): Promise; + restart(packId: string, runtimeId: string, opts?: { projectId?: string; mode?: string; config?: Record }): Promise; + down(packId: string, runtimeId: string, opts?: { projectId?: string; volumes?: boolean; removeState?: boolean }): Promise; + capabilitySummary(packId: string, runtimeId: string, opts?: { projectId?: string; mode?: string; config?: Record }): Promise; + logs(packId: string, runtimeId: string, opts?: { projectId?: string; tail?: number }): Promise; +} + +export interface PackRuntimeSupervisorDeps { + packContributionRegistry: PackContributionRegistry; + stateDir: string; + defaultCwd: string; +} + +/** + * Map an effective Hindsight-style deployment config onto a runtime START plan — + * the SINGLE source of truth shared by the marketplace pack-activation enable + * path AND the `/api/pack-runtimes/:id/{start,restart}` REST routes, so the two + * can never diverge on how a deployment config becomes supervisor start args. + * + * - `mode` (deployment) maps to a runtime manifest mode: `managed` ⇒ + * `managed-postgres`, `managed-external-postgres` ⇒ `external-postgres`. The + * external (and absent/default) deployment mode is a NON-Docker setup path, so + * `start: false` and no container is brought up. + * - The provider's `externalDatabaseUrl` is remapped onto the manifest's + * `HINDSIGHT_API_DATABASE_URL` env key, and `llmApiKey` onto + * `HINDSIGHT_API_LLM_API_KEY`, so the supervisor's config overlay satisfies + * those user-configured `secret:` env refs without seeding the global secret + * store. A value already set directly under the env key wins. + */ +export function resolveRuntimeStartPlan( + deploymentConfig: Record, +): { start: boolean; mode?: string; config: Record } { + const mode = typeof deploymentConfig.mode === "string" ? deploymentConfig.mode : "external"; + const config: Record = { ...deploymentConfig }; + if (typeof deploymentConfig.externalDatabaseUrl === "string" && deploymentConfig.externalDatabaseUrl.length > 0 + && !(typeof config.HINDSIGHT_API_DATABASE_URL === "string" && (config.HINDSIGHT_API_DATABASE_URL as string).length > 0)) { + config.HINDSIGHT_API_DATABASE_URL = deploymentConfig.externalDatabaseUrl; + } + if (typeof deploymentConfig.llmApiKey === "string" && deploymentConfig.llmApiKey.length > 0 + && !(typeof config.HINDSIGHT_API_LLM_API_KEY === "string" && (config.HINDSIGHT_API_LLM_API_KEY as string).length > 0)) { + config.HINDSIGHT_API_LLM_API_KEY = deploymentConfig.llmApiKey; + } + switch (mode) { + case "managed": return { start: true, mode: "managed-postgres", config }; + case "managed-external-postgres": return { start: true, mode: "external-postgres", config }; + default: return { start: false, config }; + } +} + +/** + * Whether a pack exposes a managed-runtime DEPLOYMENT SURFACE — i.e. a provider + * whose EFFECTIVE config actually carries a deployment `mode` (external / managed + * / managed-external-postgres), or whose activation links to that mode + * (`activeWhenConfig.mode`). Merely HAVING a provider is NOT enough: a pack with an + * unrelated provider (one with no deployment mode) has no external/managed concept, + * so it must behave EXACTLY like a provider-less runtime pack — its `on-enable` + * runtime starts in the manifest DEFAULT (Docker) mode and the consent disclosure + * shows that default, rather than being suppressed to / disclosed as the external + * (no-Docker) setup path. Shared by the marketplace activation path, the REST + * `/api/pack-runtimes/:id/start` guard, and the `/capabilities` disclosure so the + * three can never diverge. + */ +export function providerCarriesDeploymentMode( + provider: { config?: Record; activation?: { activeWhenConfig?: Record } }, + effectiveConfig?: Record, +): boolean { + const config = effectiveConfig ?? provider.config ?? {}; + if (typeof config.mode === "string" && config.mode.length > 0) return true; + const activeWhen = provider.activation?.activeWhenConfig; + return !!activeWhen && Object.prototype.hasOwnProperty.call(activeWhen, "mode"); +} + +/** + * P3 managed-runtime context resolution — the SINGLE source of truth shared by + * BOTH the LifecycleHub provider-hook path (`runtimeResolver`) and the pack-ROUTE + * dispatch path (`/api/ext/route/:name`), so a managed provider and its sibling + * routes always agree on the runtime linkage they receive. + * + * For a provider/route in a MANAGED deployment mode (`managed` / + * `managed-external-postgres`), it READS the supervisor's runtime status + the + * already-persisted API host port (from the pure capability summary) and builds + * the `ctx.runtime` linkage `{ baseUrl, headers, status }`. It NEVER starts + * Docker. External mode / no supervisor / a stopped runtime / an unknown API + * port ⇒ `undefined`, and the consumer stays dormant via its own gate. + */ +export async function resolveManagedRuntimeContext( + supervisor: PackRuntimeSupervisorLike | undefined, + opts: { packId: string; runtimeId: string; projectId?: string; config: Record }, +): Promise { + const { packId, runtimeId, projectId, config: providerConfig } = opts; + const mode = typeof providerConfig.mode === "string" ? providerConfig.mode : "external"; + if (mode !== "managed" && mode !== "managed-external-postgres") return undefined; + if (!supervisor) return undefined; + let status: PackRuntimeStatus; + try { + status = await supervisor.status(packId, runtimeId, projectId); + } catch { + return undefined; + } + let apiPort: number | undefined; + try { + const cap = await supervisor.capabilitySummary(packId, runtimeId, { projectId }); + const apiSpec = + cap.ports.find((p) => /(^|_)API_PORT$/i.test(p.key) || (p.env ? /(^|_)API_PORT$/i.test(p.env) : false)) ?? + cap.ports[0]; + if (apiSpec && typeof apiSpec.host === "number") apiPort = apiSpec.host; + } catch { + return undefined; + } + if (apiPort === undefined) return undefined; + const apiKey = typeof providerConfig.apiKey === "string" && providerConfig.apiKey.length > 0 ? providerConfig.apiKey : undefined; + const headers: Record = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; + return { baseUrl: `http://127.0.0.1:${apiPort}`, headers, status: status.status }; +} + +export type PackRuntimeSupervisorFactory = (deps: PackRuntimeSupervisorDeps) => PackRuntimeSupervisorLike | undefined; + +let _packRuntimeSupervisorFactory: PackRuntimeSupervisorFactory | null = null; + +/** + * Register an alternative pack-runtime-supervisor factory. Called by test + * harnesses to inject a fully-mocked supervisor (no Docker daemon) so the + * /api/pack-runtimes/* routes can be exercised end-to-end. Pass `null` to clear. + * When unset, production builds the real PackRuntimeSupervisor in start(). + */ +export function registerPackRuntimeSupervisorFactory(factory: PackRuntimeSupervisorFactory | null): void { + _packRuntimeSupervisorFactory = factory; +} + export function createGateway(config: GatewayConfig) { const stateDir = bobbitStateDir(); const configDir = bobbitConfigDir(); @@ -1285,7 +1523,24 @@ export function createGateway(config: GatewayConfig) { const persisted = getPackStore().getSync>(packId, providerConfigStoreKey(providerId)); return persisted && typeof persisted === "object" ? persisted : undefined; }, + // Disabled-runtime activation override (DisabledRefs.runtimes): a runtime + // disabled via pack_activation is dropped from the pack's contributions, so the + // supervisor's registry lookup 404s and runtime listings omit it. + (scope, projectId, packName) => packActivationStore(scope as PackScope, projectId)?.getPackActivation(scope as PackOrderScope, packName).runtimes ?? [], ); + // P2 pack managed-runtime supervisor handle (Docker-backed). Declared HERE — before + // the LifecycleHub — so the hub's runtime-context resolver can consult it lazily at + // dispatch time. The production instance is built lazily in start(); a registered + // test factory is consulted FRESH per call so registerPackRuntimeSupervisorFactory(null) + // reverts cleanly with no stale mock cached. Boot/install/update/list/status never + // start Docker (see the design invariants); the runtime resolver only READS status + // + the already-persisted API host port to inject ctx.runtime for managed providers. + let realPackRuntimeSupervisor: PackRuntimeSupervisorLike | undefined = undefined; + const getActivePackRuntimeSupervisor = (): PackRuntimeSupervisorLike | undefined => + _packRuntimeSupervisorFactory + ? (_packRuntimeSupervisorFactory({ packContributionRegistry, stateDir, defaultCwd: config.defaultCwd }) ?? realPackRuntimeSupervisor) + : realPackRuntimeSupervisor; + sessionManager.lifecycleHub = new LifecycleHub({ registry: packContributionRegistry, moduleHost, @@ -1323,9 +1578,27 @@ export function createGateway(config: GatewayConfig) { return { baseUrl: "", token: "" }; } }, + // P3 — managed-runtime context injection. For a provider declaring a `runtime` + // linkage in a MANAGED deployment mode, resolve ctx.runtime from the supervisor + // WITHOUT starting Docker: read the runtime status + the already-persisted API + // host port (from the pure capability summary). External mode / a stopped runtime + // / an unknown port ⇒ undefined, and the provider stays dormant via its own gate. + runtimeResolver: async ({ packId, runtimeId, projectId, config: providerConfig }) => + resolveManagedRuntimeContext(getActivePackRuntimeSupervisor(), { packId, runtimeId, projectId, config: providerConfig }), }); routeRegistry = new RouteRegistry(packContributionRegistry); + // P2: pack managed-runtime supervisor (Docker-backed). Built from the same + // pack-contribution registry. This closure holds ONLY the production (real) + // supervisor; a registered test factory is consulted FRESH per-request below + // and never cached here. That way registerPackRuntimeSupervisorFactory(null) + // immediately reverts to the production instance (or 503) with no stale mock + // left in the closure across in-process E2E tests. The real supervisor is + // loaded lazily in start() (dynamic import keeps this wiring compilable even + // before the supervisor module is merged — routes then return 503). The handle + // itself + the getActivePackRuntimeSupervisor() resolver are declared above (before + // the LifecycleHub, which consults the resolver for ctx.runtime injection). + // pack-schema-v1 §7: feed pack_activation into the roles/tools cascade so a // disabled entity is dropped BEFORE precedence merge (a shadow may reappear). configCascade.setPackActivationProvider({ @@ -1334,6 +1607,44 @@ export function createGateway(config: GatewayConfig) { }, }); + // ── Default-disabled built-in packs (e.g. Hindsight) ───────────────────── + // A built-in first-party pack may ship `defaultDisabled: true` in its manifest: + // it lists in the Marketplace built-in band but resolves DORMANT (tools, + // provider, entrypoints, runtime all absent) on a fresh server until the user + // enables it OR it is "already configured" (live-setup preservation). We inject + // a READ-TIME overlay into the SERVER-scope activation store so the single + // getPackActivation seam (cascade, registry, tool-manager, slash-skills, + // Marketplace endpoints) all observe the same effective state. The overlay is + // never persisted, so the dormancy invariant holds and an explicit user toggle + // (a persisted record, or the force-enabled marker) always wins. + // + // The static per-pack info + force-enabled marker live in module-scope helpers + // (getDefaultDisabledInfo / readForceEnabledPacks / writeForceEnabledPacks) so + // the activation PUT in handleApiRoute (a top-level function) shares them. Here + // we only INJECT the overlay resolver into the SERVER-scope store: it consults + // the memoized static info plus the live gates (force-enabled marker + persisted + // provider config) on each call (cheap, and only for default-disabled packs). + projectConfigStore.setDefaultActivationResolver((scope, packName, stored): DisabledRefs | undefined => { + if (scope !== "server") return undefined; + const info = getDefaultDisabledInfo(packName, projectConfigStore); + if (!info) return undefined; + const isForceEnabled = readForceEnabledPacks(projectConfigStore).has(packName); + let isConfigured = false; + if (!isForceEnabled && Object.keys(stored).length === 0) { + for (const pid of info.providerIds) { + const cfg = getPackStore().getSync>(info.packId, providerConfigStoreKey(pid)); + if (isProviderConfigConfigured(cfg)) { isConfigured = true; break; } + } + } + return resolveDefaultActivationOverlay({ + scope, packName, stored, + isDefaultDisabled: true, + isForceEnabled, + isConfigured, + allDisabledRefs: info.allDisabled, + }); + }); + const staffManager = new StaffManager(projectContextManager); sessionManager.setStaffManager(staffManager); @@ -1448,6 +1759,29 @@ export function createGateway(config: GatewayConfig) { taskManager: new TaskManager(taskStore), roleStore, projectContextManager, + goalCompletedDispatcher: async (ctx) => { + const hub = sessionManager.lifecycleHub as unknown as { + dispatchGoalCompleted?: (c: typeof ctx) => Promise; + } | undefined; + if (hub && typeof hub.dispatchGoalCompleted === "function") { + await hub.dispatchGoalCompleted(ctx); + } + }, + hasGoalCompletedProviders: (goalId, projectId) => { + const hub = sessionManager.lifecycleHub; + return !!hub?.hasProvidersForHooks(projectId, ["goalCompleted"], goalId); + }, + resolveGoalPullRequest: (goalId) => { + const pr = prStatusStore.get(goalId) as any; + if (!pr) return undefined; + return { + url: typeof pr.url === "string" ? pr.url : undefined, + number: typeof pr.number === "string" || typeof pr.number === "number" ? pr.number : undefined, + title: typeof pr.title === "string" ? pr.title : undefined, + state: typeof pr.state === "string" ? pr.state : undefined, + headSha: typeof pr.headSha === "string" ? pr.headSha : undefined, + }; + }, toolManager, orchestrationCore, }); @@ -1599,7 +1933,14 @@ export function createGateway(config: GatewayConfig) { // Enable via BOBBIT_TIMING_LOG=1 to print "[timing] METHOD path ms" for each API call. const _timingEnabled = process.env.BOBBIT_TIMING_LOG === "1"; const _timingStart = _timingEnabled ? performance.now() : 0; - await handleApiRoute(url, req, res, sessionManager, config, colorStore, prStatusStore, teamManager, orchestrationCore, roleManager, toolManager, projectContextManager, bgProcessManager, staffManager, verificationHarness, preferencesStore, projectConfigStore, groupPolicyStore, broadcastToGoal, broadcastToAll, sandboxManager, projectRegistry, configCascade, sandboxScope, sandboxTokenStore, reviewAnnotationStore, broadcastToSession, roleStore, inboxManager, marketplaceSourceStore, marketplaceInstaller, cookieStore, actionDispatcher, routeDispatcher, routeRegistry, packContributionRegistry); + // P2: a registered pack-runtime-supervisor factory is authoritative — in- + // process test harnesses register a (mocked) one AFTER gateway start, so it + // must override the real supervisor built in start(). Derived FRESH each + // request (never cached), so clearing the factory reverts to the production + // instance with no stale mock. No-op in production (factory stays null). Shares + // the same resolver the LifecycleHub uses so the route + ctx.runtime paths agree. + const packRuntimeSupervisor: PackRuntimeSupervisorLike | undefined = getActivePackRuntimeSupervisor(); + await handleApiRoute(url, req, res, sessionManager, config, colorStore, prStatusStore, teamManager, orchestrationCore, roleManager, toolManager, projectContextManager, bgProcessManager, staffManager, verificationHarness, preferencesStore, projectConfigStore, groupPolicyStore, broadcastToGoal, broadcastToAll, sandboxManager, projectRegistry, configCascade, sandboxScope, sandboxTokenStore, reviewAnnotationStore, broadcastToSession, roleStore, inboxManager, marketplaceSourceStore, marketplaceInstaller, cookieStore, actionDispatcher, routeDispatcher, routeRegistry, packContributionRegistry, packRuntimeSupervisor); if (_timingEnabled) { const dur = performance.now() - _timingStart; if (dur >= 100) console.log(`[timing] ${req.method} ${url.pathname}${url.search} ${dur.toFixed(1)}ms`); @@ -2023,6 +2364,31 @@ export function createGateway(config: GatewayConfig) { // Runs before session restore so models.json is written before // any agent subprocesses start. await startupAigwCheck(preferencesStore); + // P2: build the real pack-runtime supervisor unless a test factory already + // supplied a (mocked) one. All Docker execution is encapsulated in the + // supervisor; rendered env files live under the server state dir. + if (!realPackRuntimeSupervisor && !_packRuntimeSupervisorFactory) { + try { + const { SecretsStore } = await import("./agent/secrets-store.js"); + const runtimeDataDir = path.join(stateDir, "pack-runtimes"); + // Production-safe resolver context: declared generated secrets are + // created+persisted via SecretsStore and declared host ports via a + // file-backed FilePortStore, so real pack runtimes (e.g. Hindsight) + // resolve their env refs instead of throwing before Docker starts. + realPackRuntimeSupervisor = new PackRuntimeSupervisor({ + registry: packContributionRegistry, + runtimeDataDir, + // STABLE across gateway restarts (persisted under the state dir): a + // random per-process suffix would change the compose project name on + // every restart and orphan the still-running containers. + serverIdentitySuffix: getOrCreatePackRuntimeServerIdentity(stateDir), + secretsStore: new SecretsStore(stateDir), + portStore: new FilePortStore(path.join(runtimeDataDir, "ports.json")), + }); + } catch (err) { + console.warn(`[pack-runtimes] supervisor unavailable: ${(err as Error)?.message ?? err}`); + } + } writeContextWindowOverrides(); writeOpenAIModelAdditions(); @@ -2634,6 +3000,7 @@ async function handleApiRoute( routeDispatcherArg?: RouteDispatcher, routeRegistryArg?: RouteRegistry, packContributionRegistryArg?: PackContributionRegistry, + packRuntimeSupervisor?: PackRuntimeSupervisorLike, ) { // These are always wired by the sole caller; the optional markers are only to avoid // touching every existing signature site. @@ -2661,7 +3028,7 @@ async function handleApiRoute( // marketplace pack-list mutation (design §9.1 / finding #1) so newly // installed/updated/removed market-pack tool roots are re-scanned (Windows // coarse-mtime can otherwise serve a stale scan after a re-copy update). - const invalidateResolverCaches = (): void => { invalidateSlashSkillsCache(); __resetToolScanCache(); dispatcher.invalidate(); routeDispatcher.invalidate(); routeRegistry.invalidate(); packContributionRegistry.invalidate(); }; + const invalidateResolverCaches = (): void => { invalidateSlashSkillsCache(); __resetToolScanCache(); dispatcher.invalidate(); routeDispatcher.invalidate(); routeRegistry.invalidate(); packContributionRegistry.invalidate(); clearDefaultDisabledInfoCache(); }; // Host-owned activation-cache invalidation: a pack persisting provider config // (key `provider-config:*`) must drop the activation-filtered provider index so // a dormant provider (e.g. Hindsight gaining an externalUrl) activates WITHOUT a @@ -4378,11 +4745,6 @@ async function handleApiRoute( // sessionShutdown are gateway-internal dispatches and intentionally have NO // public endpoint. // - // Delimiters MUST stay byte-identical to provider-bridge-extension.ts's - // stripDelimitedTail() so the system-prompt tail is idempotent turn-over-turn. - const DYNAMIC_CONTEXT_START = ""; - const DYNAMIC_CONTEXT_END = ""; - // Resolve a session's lifecycle dispatch context from live or persisted state. // Returns undefined when the session is unknown (→ 404 for the hook endpoints). const resolveHookCtx = (id: string): Omit | undefined => { @@ -4420,7 +4782,7 @@ async function handleApiRoute( } const hub = sessionManager.lifecycleHub; if (!hub) { - json({ tail: "", blocks: [] }); + json({ content: "", blocks: [] }); return; } try { @@ -4430,11 +4792,9 @@ async function handleApiRoute( prompt: typeof body?.prompt === "string" ? body.prompt : undefined, turn: typeof turnIndex === "number" && Number.isFinite(turnIndex) ? { index: turnIndex } : undefined, }); - const tail = blocks.length - ? `\n${DYNAMIC_CONTEXT_START}\n${blocks.map(fenceBlock).join("\n\n")}\n${DYNAMIC_CONTEXT_END}` - : ""; + const content = blocks.length ? blocks.map(fenceBlock).join("\n\n") : ""; // Best-effort: refresh the persisted prompt-sections snapshot so the - // inspector reflects this turn's dynamic-context tail. Non-fatal. + // inspector reflects this turn's dynamic-context blocks. Non-fatal. try { const parts = sessionManager.getPromptParts(sessionId); if (parts) { @@ -4445,7 +4805,7 @@ async function handleApiRoute( console.debug(`[provider-hooks] prompt-sections refresh skipped for ${sessionId}:`, err); } json({ - tail, + content, blocks: blocks.map((b) => ({ id: b.id, providerId: b.providerId, title: b.title, tokenEstimate: b.tokenEstimate })), }); } catch (err: any) { @@ -5991,6 +6351,293 @@ async function handleApiRoute( return; } + // ── P2: pack managed-runtime (Docker-backed) supervisor REST surface ────── + // GET /api/pack-runtimes?projectId= → { runtimes: PackRuntimeStatus[] } + // POST /api/pack-runtimes/:id/start → status (after ensure/start) + // POST /api/pack-runtimes/:id/stop → status (after stop) + // POST /api/pack-runtimes/:id/restart → status (after restart) + // GET /api/pack-runtimes/:id/logs?tail= → { logs, status? } + // The `:id` is the URL-safe encodePackRuntimeId(packId, runtimeId). Admin-bearer + // only (gated before handleApiRoute). All Docker execution lives in the + // supervisor; tests inject a fully-mocked supervisor (no daemon). + if (url.pathname === "/api/pack-runtimes" && req.method === "GET") { + if (!packRuntimeSupervisor) { json({ error: "pack runtime supervisor unavailable" }, 503); return; } + const projectId = url.searchParams.get("projectId") || undefined; + try { + const statuses = await packRuntimeSupervisor.list(projectId); + // Re-derive the API id from {packId, runtimeId} so it always round-trips + // through decodePackRuntimeId regardless of the supervisor's internal id. + const runtimes = statuses.map((s) => ({ ...s, id: encodePackRuntimeId(s.packId, s.runtimeId) })); + json({ runtimes }); + } catch (err) { + jsonError(500, err); + } + return; + } + + // GET /api/pack-runtimes/:id/capabilities?projectId=&mode= → capability summary + // Pre-start consent disclosure (P3 §8): images/services, host ports, the + // managed data/volume path, the start policy, and the memory/trust copy. Pure + // (no Docker), so the Market UI can render it BEFORE the user consents. + const packRuntimeCapMatch = url.pathname.match(/^\/api\/pack-runtimes\/([^/]+)\/capabilities$/); + if (packRuntimeCapMatch) { + if (req.method !== "GET") { json({ error: "method not allowed" }, 405); return; } + if (!packRuntimeSupervisor) { json({ error: "pack runtime supervisor unavailable" }, 503); return; } + let packId: string, runtimeId: string; + try { ({ packId, runtimeId } = decodePackRuntimeId(packRuntimeCapMatch[1])); } + catch (err) { if (err instanceof PackRuntimeBadRequestError) { jsonError(400, err); return; } jsonError(500, err); return; } + const projectId = url.searchParams.get("projectId") || undefined; + const rawMode = url.searchParams.get("mode"); + const requestedMode = rawMode !== null && rawMode.trim().length > 0 ? rawMode.trim() : undefined; + // The disclosure is DEPLOYMENT-mode aware (external / managed / managed-external- + // postgres). The caller may pass an explicit mode; absent that, resolve the + // EFFECTIVE deployment mode from the pack's provider config so the external + // (no-Docker) setup path is reachable even when the UI does not know the mode. + // Build the EFFECTIVE deployment config the SAME way the activation path does + // (each provider's flat schema defaults overlaid with its persisted store + // config), so the consent disclosure reflects custom settings — most importantly + // a custom `dataDir` bind path — rather than schema defaults that would diverge + // from what activation actually mounts. + // + // Read RAW (activation-UNFILTERED) contributions, NOT `getPack` — the latter + // drops a provider whose activation gate is still unsatisfied (e.g. Hindsight's + // external-mode `memory` provider before `externalUrl` is set), which would + // misclassify fresh/default Hindsight as provider-less and disclose the Docker + // default mode instead of the external (no-Docker) setup path. Mirrors the + // activation path's raw `loadPackContributions` derivation. + const deploymentConfig: Record = {}; + let hasDeploymentSurface = false; + { + const pack = packContributionRegistry.getRawPack(projectId, packId); + for (const p of pack?.providers ?? []) { + const merged: Record = { ...(p.config ?? {}) }; + const persisted = getPackStore().getSync>(packId, providerConfigStoreKey(p.id)); + if (persisted && typeof persisted === "object") Object.assign(merged, persisted); + Object.assign(deploymentConfig, merged); + if (providerCarriesDeploymentMode(p, merged)) hasDeploymentSurface = true; + } + } + // Resolve the EFFECTIVE deployment mode. With NO deployment surface (a provider- + // less runtime pack, or a pack whose only provider carries no deployment mode) + // there is no external/managed concept to honour, so the disclosure must show the + // runtime's manifest DEFAULT (Docker) mode/services/ports — the SAME no-surface + // fallback the activation/start paths use (they start such runtimes in the + // manifest default mode). Only fall back to `external` when a deployment surface + // exists but selects no managed mode. + const deploymentMode = requestedMode + ?? (typeof deploymentConfig.mode === "string" && deploymentConfig.mode.length > 0 + ? deploymentConfig.mode + : (hasDeploymentSurface ? "external" : undefined)); + // Deployment mode → runtime manifest mode. `external` is the non-Docker setup path. + const RUNTIME_MODE_FOR_DEPLOYMENT: Record = { + managed: "managed-postgres", + "managed-external-postgres": "external-postgres", + }; + try { + if (deploymentMode === undefined) { + // No deployment surface: disclose the runtime's manifest DEFAULT (Docker) + // mode/services/ports (capabilitySummary with no mode picks the first + // manifest mode). dockerRequired:true mirrors the start path bringing this + // runtime up in its default mode. + const summary = await packRuntimeSupervisor.capabilitySummary(packId, runtimeId, { projectId, config: deploymentConfig }); + json({ ...summary, id: encodePackRuntimeId(summary.packId, summary.runtimeId), dockerRequired: true }); + return; + } + if (deploymentMode === "external") { + // External: derive descriptor/trust from the default manifest mode but disclose + // NO services/ports and flag dockerRequired:false, so the UI shows setup + // guidance instead of a Docker start disclosure. Works without Docker. + const base = await packRuntimeSupervisor.capabilitySummary(packId, runtimeId, { projectId }); + json({ ...base, id: encodePackRuntimeId(base.packId, base.runtimeId), mode: "external", services: [], images: [], ports: [], volumePath: undefined, dockerRequired: false }); + return; + } + const runtimeMode = RUNTIME_MODE_FOR_DEPLOYMENT[deploymentMode] ?? deploymentMode; + const summary = await packRuntimeSupervisor.capabilitySummary(packId, runtimeId, { projectId, mode: runtimeMode, config: deploymentConfig }); + json({ ...summary, id: encodePackRuntimeId(summary.packId, summary.runtimeId), dockerRequired: true }); + } catch (err) { + if (err instanceof PackRuntimeNotFoundError) { jsonError(404, err); return; } + if (err instanceof PackRuntimeBadRequestError) { jsonError(400, err); return; } + jsonError(500, err); + } + return; + } + + // POST /api/pack-runtimes/:id/down { volumes?: boolean, removeState?: boolean } + // `docker compose down`. Default (no volumes/removeState) preserves bind-mounted + // data — the uninstall primitive. `volumes: true` + `removeState: true` is the + // explicit purge. Admin-bearer only (gated before handleApiRoute). + const packRuntimeDownMatch = url.pathname.match(/^\/api\/pack-runtimes\/([^/]+)\/down$/); + if (packRuntimeDownMatch) { + if (req.method !== "POST") { json({ error: "method not allowed" }, 405); return; } + if (!packRuntimeSupervisor) { json({ error: "pack runtime supervisor unavailable" }, 503); return; } + let packId: string, runtimeId: string; + try { ({ packId, runtimeId } = decodePackRuntimeId(packRuntimeDownMatch[1])); } + catch (err) { if (err instanceof PackRuntimeBadRequestError) { jsonError(400, err); return; } jsonError(500, err); return; } + const projectId = url.searchParams.get("projectId") || undefined; + const bodyText = await readBodyText(req); + if (bodyText === null) { json({ error: "request body unreadable or too large" }, 400); return; } + let body: Record = {}; + const trimmed = bodyText.trim(); + if (trimmed.length > 0) { + let parsed: unknown; + try { parsed = JSON.parse(trimmed); } catch { json({ error: "malformed JSON body" }, 400); return; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { json({ error: "malformed JSON body" }, 400); return; } + body = parsed as Record; + } + const volumes = body.volumes === true; + const removeState = body.removeState === true; + try { + const status = await packRuntimeSupervisor.down(packId, runtimeId, { projectId, volumes, removeState }); + json({ ...status, id: encodePackRuntimeId(status.packId, status.runtimeId) }); + } catch (err) { + if (err instanceof PackRuntimeNotFoundError) { jsonError(404, err); return; } + if (err instanceof PackRuntimeBadRequestError) { jsonError(400, err); return; } + jsonError(500, err); + } + return; + } + + const packRuntimeMatch = url.pathname.match(/^\/api\/pack-runtimes\/([^/]+)\/(start|stop|restart|logs)$/); + if (packRuntimeMatch) { + const action = packRuntimeMatch[2] as "start" | "stop" | "restart" | "logs"; + const wantsGet = action === "logs"; + if (wantsGet ? req.method !== "GET" : req.method !== "POST") { + json({ error: "method not allowed" }, 405); + return; + } + if (!packRuntimeSupervisor) { json({ error: "pack runtime supervisor unavailable" }, 503); return; } + + // Map supervisor failures to status codes: NotFound → 404, BadRequest → 400. + const handleErr = (err: unknown): void => { + if (err instanceof PackRuntimeNotFoundError) { jsonError(404, err); return; } + if (err instanceof PackRuntimeBadRequestError) { jsonError(400, err); return; } + jsonError(500, err); + }; + + // Decode the URL-safe id (raw path segment — decodePackRuntimeId percent- + // decodes the halves itself). Malformed → PackRuntimeBadRequestError → 400. + let packId: string; + let runtimeId: string; + try { + ({ packId, runtimeId } = decodePackRuntimeId(packRuntimeMatch[1])); + } catch (err) { handleErr(err); return; } + const projectId = url.searchParams.get("projectId") || undefined; + + if (action === "logs") { + // Tail validation/clamping is owned by the supervisor (clampTail): a + // non-numeric tail throws PackRuntimeBadRequestError → 400; out-of-range + // values are clamped. Pass the raw query value through. + const rawTail = url.searchParams.get("tail"); + const tail = rawTail !== null && rawTail !== "" ? (Number(rawTail) as number) : undefined; + try { + const logs = await packRuntimeSupervisor.logs(packId, runtimeId, { projectId, tail }); + json({ logs }); + } catch (err) { + // Surface a missing-Docker install as a consistent docker-unavailable + // shape (200 with empty logs + status) rather than hiding it behind an + // empty body or a generic 500. + if (err instanceof PackRuntimeDockerUnavailableError) { + json({ logs: "", status: "docker-unavailable", message: err.message }); + return; + } + handleErr(err); + } + return; + } + + // start | stop | restart — optional `mode` from the POST body. An EMPTY body + // is valid (default mode); a NON-EMPTY but malformed-JSON body is a client + // error — answer 400 and do NOT invoke the supervisor (never silently treat + // garbage as `{}` and mutate the default mode). + const bodyText = await readBodyText(req); + if (bodyText === null) { json({ error: "request body unreadable or too large" }, 400); return; } + let body: Record = {}; + const trimmedBody = bodyText.trim(); + if (trimmedBody.length > 0) { + let parsed: unknown; + try { parsed = JSON.parse(trimmedBody); } catch { json({ error: "malformed JSON body" }, 400); return; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { json({ error: "malformed JSON body" }, 400); return; } + body = parsed as Record; + } + let mode: string | undefined; + let explicitMode = false; + let startConfig: Record | undefined; + if (action !== "stop") { + const rawMode = (body as { mode?: unknown }).mode; + if (rawMode !== undefined && rawMode !== null) { + if (typeof rawMode !== "string" || rawMode.trim().length === 0) { json({ error: "malformed mode" }, 400); return; } + mode = rawMode; + explicitMode = true; + } + // Derive the saved provider deployment config and remap it onto the + // runtime's env keys EXACTLY like marketplace activation start does + // (resolveRuntimeStartPlan — the shared source of truth). Without this the + // route would forward only {projectId, mode} and a managed start would fail + // to resolve HINDSIGHT_API_LLM_API_KEY / HINDSIGHT_API_DATABASE_URL. + const deploymentConfig: Record = {}; + let hasDeploymentSurface = false; + { + // RAW (activation-UNFILTERED) contributions — NOT `getPack`. A dormant + // provider (e.g. Hindsight's external-mode `memory` provider before + // `externalUrl` is configured) is dropped from `getPack().providers`, + // which would hide the deployment surface and let the no-surface fallback + // start Docker in the runtime's default (managed) mode. Reading raw keeps + // the external-mode guard below in force for fresh/default Hindsight. + const pack = packContributionRegistry.getRawPack(projectId, packId); + for (const p of pack?.providers ?? []) { + const merged: Record = { ...(p.config ?? {}) }; + const persisted = getPackStore().getSync>(packId, providerConfigStoreKey(p.id)); + if (persisted && typeof persisted === "object") Object.assign(merged, persisted); + Object.assign(deploymentConfig, merged); + // A deployment surface requires a provider that ACTUALLY carries a + // deployment mode — an unrelated provider (no mode) must behave like a + // provider-less runtime so the no-surface fallback below applies. + if (providerCarriesDeploymentMode(p, merged)) hasDeploymentSurface = true; + } + } + const plan = resolveRuntimeStartPlan(deploymentConfig); + startConfig = plan.config; + // Respect a saved EXTERNAL (or default/unset) deployment mode: that is the + // non-Docker setup path (plan.start === false), so there is NO managed + // runtime to bring up. Without an explicit body mode the route must NOT + // silently fall through to the runtime's first manifest mode (managed) and + // start Docker — activation already gates on plan.start, and the REST surface + // must agree. Answer 409 with a clear external/no-runtime shape; a caller that + // genuinely wants to start a managed stack must pass an explicit `mode`. + // + // The guard applies ONLY when the pack actually exposes a deployment-config + // surface (a provider whose config carries the mode). A runtime with no such + // surface has no external/managed concept to honor, so it keeps the legacy + // supervisor-default-mode behaviour and an unknown pack still reaches the + // supervisor (→ 404) rather than being masked by this 409. + if (hasDeploymentSurface && !plan.start && !explicitMode) { + const deploymentMode = typeof deploymentConfig.mode === "string" && deploymentConfig.mode.length > 0 + ? deploymentConfig.mode + : "external"; + json({ + error: "runtime is configured for external (non-managed) mode; no Docker runtime to start", + mode: deploymentMode, + status: "stopped", + started: false, + id: encodePackRuntimeId(packId, runtimeId), + }, 409); + return; + } + // An explicit body mode (a runtime manifest mode) overrides the + // deployment-derived plan mode; otherwise use the plan's mapped mode. + if (mode === undefined) mode = plan.mode; + } + try { + const status = action === "stop" + ? await packRuntimeSupervisor.stop(packId, runtimeId, { projectId }) + : action === "start" + ? await packRuntimeSupervisor.start(packId, runtimeId, { projectId, mode, config: startConfig }) + : await packRuntimeSupervisor.restart(packId, runtimeId, { projectId, mode, config: startConfig }); + json({ ...status, id: encodePackRuntimeId(status.packId, status.runtimeId) }); + } catch (err) { handleErr(err); } + return; + } + // Fix B: there is NO server-side own-session message poster — driving the agent // is a client-only, user-activation + session-secret gated capability. A server // route/action handler has no user gesture, so the server Host API exposes no @@ -6407,10 +7054,9 @@ async function handleApiRoute( const routeHeaderSid = Array.isArray(headerSessionId) ? headerSessionId[0] : headerSessionId; // Resolve the tool through the SESSION's project-scoped tool manager (same // no-split-brain resolution the action + store endpoints use). - const routeSessionProjectId = routeHeaderSid - ? (sessionManager.getSession(routeHeaderSid)?.projectId - ?? sessionManager.getPersistedSession(routeHeaderSid)?.projectId) - : undefined; + const routeHeaderLive = routeHeaderSid ? sessionManager.getSession(routeHeaderSid) : undefined; + const routeHeaderPersisted = routeHeaderSid ? sessionManager.getPersistedSession(routeHeaderSid) : undefined; + const routeSessionProjectId = routeHeaderLive?.projectId ?? routeHeaderPersisted?.projectId; const routeToolManager = resolveActionToolManager( toolManager, routeSessionProjectId ? projectContextManager.getOrCreate(routeSessionProjectId)?.toolManager : undefined, @@ -6485,17 +7131,53 @@ async function handleApiRoute( // Drop activation caches when a route persists provider config (host-owned). onStoreWrite: notePackStoreWrite, }); + // P3/P4 — managed-runtime context injection for pack ROUTES. Mirror the + // LifecycleHub provider-hook path: if the routed pack has a provider declaring a + // `runtime` linkage and its EFFECTIVE config selects a managed deployment mode, + // resolve `ctx.runtime` from the supervisor WITHOUT starting Docker so the route + // handlers reach the locally-running managed runtime (e.g. Hindsight status/recall). + // External mode / no runtime / a stopped runtime ⇒ undefined, and the route stays + // dormant via its own `isActive(cfg, ctx.runtime)` gate. Resolution failure is + // non-fatal (the route just runs without runtime). + let routeRuntime: RuntimeContext | undefined; + try { + const pack = packContributionRegistry.getPack(routeSessionProjectId, ident.packId); + const runtimeProvider = pack?.providers.find((p) => typeof p.runtime === "string" && p.runtime.length > 0); + if (runtimeProvider?.runtime) { + routeRuntime = await resolveManagedRuntimeContext(packRuntimeSupervisor, { + packId: ident.packId, + runtimeId: runtimeProvider.runtime, + projectId: routeSessionProjectId, + config: runtimeProvider.config ?? {}, + }); + } + } catch { + routeRuntime = undefined; // non-fatal — the route runs without ctx.runtime + } const start = Date.now(); try { // The session working dir the confined worker uses as its process.cwd() // (tool parity — prefer the worktree path; fall back to the recorded cwd). + const routeLive = sessionManager.getSession(guard.sessionId); const routePs = sessionManager.getPersistedSession(guard.sessionId); const routeWorkingDir = routePs?.worktreePath ?? routePs?.cwd; + const routeGoalId = routeLive?.goalId ?? routeLive?.teamGoalId ?? routePs?.goalId ?? routePs?.teamGoalId; + const routeRoleName = routeLive?.role ?? routePs?.role; const result = await routeDispatcher.dispatch( resolved.modulePath, resolved.packRoot, routeName, - { host, sessionId: guard.sessionId, toolUseId: toolUseId ?? "", tool: ident.contributionId, projectId: routeSessionProjectId, workingDir: routeWorkingDir }, + { + host, + sessionId: guard.sessionId, + toolUseId: toolUseId ?? "", + tool: ident.contributionId, + projectId: routeSessionProjectId, + ...(routeGoalId ? { goalId: routeGoalId } : {}), + ...(routeRoleName ? { roleName: routeRoleName } : {}), + workingDir: routeWorkingDir, + ...(routeRuntime ? { runtime: routeRuntime } : {}), + }, { method, query, body: init.body }, ); console.log(`[ext-route] name=${routeName} tool=${routeTool ?? ident.contributionId} packId=${ident.packId} session=${guard.sessionId} outcome=ok durationMs=${Date.now() - start}`); @@ -6509,6 +7191,118 @@ async function handleApiRoute( return; } + // GET/POST /api/ext/pack-route/:packId/:routeName — SESSIONLESS admin access to a + // BUILT-IN pack's route (Hindsight UX polish). The Marketplace must read built-in + // Hindsight config/status, AND write Hindsight config, after `#/market` navigation + // when there is no active chat session, so the surface-token path + // (`/api/ext/surface-token` → `/api/ext/route`) 403s. This additive route serves + // the SAME pack-level route module WITHOUT a bound session. It is narrowly scoped + // so it cannot widen the extension threat model: + // • Admin-bearer only (gated before handleApiRoute) — the trusted app shell. + // • BUILT-IN first-party packs only — a same-realm third-party pack cannot use + // this sessionless seam to read or write another pack's route output. + // • GET → any route (pure read). POST → ALLOWLISTED to the `config` route name + // ONLY (the built-in config write); any other routeName under POST is rejected + // 403, so this is NOT a general write seam — it is purely the GET seam's + // config-write sibling. The `config` route validates + persists to the pack + // store (CONFIG_INVALID for bad input) and returns the redacted effective + // config. + // CRITICAL: this path NEVER starts Docker and works with NO session — POST only + // persists config to the pack store. `ctx.runtime` is resolved WITHOUT starting + // Docker (mirrors `/api/ext/route`), preserving the no-Docker-auto-start invariant. + const packRouteMatch = url.pathname.match(/^\/api\/ext\/pack-route\/([^/]+)\/([^/]+)$/); + if (packRouteMatch && (req.method === "GET" || req.method === "POST")) { + const reqPackId = decodeURIComponent(packRouteMatch[1]); + const routeName = decodeURIComponent(packRouteMatch[2]); + const isWrite = req.method === "POST"; + const projectId = url.searchParams.get("projectId") || undefined; + // POST is allowlisted to the `config` route ONLY — never a general write seam. + if (isWrite && routeName !== "config") { + json({ error: "sessionless pack-route writes are available only for the 'config' route" }, 403); + return; + } + // Parse the JSON body for the config write. An empty body is rejected for POST + // (a config write must carry overrides); malformed JSON is a 400 client error. + let writeBody: Record = {}; + if (isWrite) { + const bodyText = await readBodyText(req); + if (bodyText === null) { json({ error: "request body unreadable or too large" }, 400); return; } + const trimmed = bodyText.trim(); + if (trimmed.length === 0) { json({ error: "config write requires a JSON body" }, 400); return; } + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + json({ error: "config write body must be a JSON object" }, 400); + return; + } + writeBody = parsed as Record; + } catch { + json({ error: "config write body must be valid JSON" }, 400); + return; + } + } + // Restrict to BUILT-IN first-party packs (same enumeration the Installed list + // uses to synthesise built-in rows), keyed by the STRUCTURAL packId. + const builtinPackIds = new Set( + builtinFirstPartyPackEntries(resolveBuiltinPacksDir()) + .filter((e) => e.manifest) + .map((e) => packIdFromRoot(e.path)), + ); + if (!builtinPackIds.has(reqPackId)) { + json({ error: "sessionless pack-route access is available only to built-in packs" }, 403); + return; + } + const resolved = routeRegistry.resolve(reqPackId, routeName, projectId); + if (!resolved) { + json({ error: `pack "${reqPackId}" declares no route "${routeName}"` }, 404); + return; + } + const host = createServerHostApi({ + sessionId: "", + toolUseId: undefined, + packId: reqPackId, + contributionId: "", + packStore: getPackStore(), + orchestrationCore, + readChildStatus: (id: string) => sessionManager.getSession(id)?.status, + onStoreWrite: notePackStoreWrite, + }); + // Managed-runtime context injection (NO Docker start) — mirror `/api/ext/route`. + let packRouteRuntime: RuntimeContext | undefined; + try { + const pack = packContributionRegistry.getPack(projectId, reqPackId); + const runtimeProvider = pack?.providers.find((p) => typeof p.runtime === "string" && p.runtime.length > 0); + if (runtimeProvider?.runtime) { + packRouteRuntime = await resolveManagedRuntimeContext(packRuntimeSupervisor, { + packId: reqPackId, + runtimeId: runtimeProvider.runtime, + projectId, + config: runtimeProvider.config ?? {}, + }); + } + } catch { + packRouteRuntime = undefined; // non-fatal — the route runs without ctx.runtime + } + const start = Date.now(); + try { + const result = await routeDispatcher.dispatch( + resolved.modulePath, + resolved.packRoot, + routeName, + { host, sessionId: "", toolUseId: "", tool: "", projectId, ...(packRouteRuntime ? { runtime: packRouteRuntime } : {}) }, + isWrite ? { method: "POST", body: writeBody } : { method: "GET" }, + ); + console.log(`[ext-pack-route] name=${routeName} packId=${reqPackId} method=${isWrite ? "POST" : "GET"} sessionless outcome=ok durationMs=${Date.now() - start}`); + json(result ?? null); + } catch (err) { + const status = err instanceof ActionError ? err.status : 500; + const message = err instanceof Error ? err.message : String(err); + console.warn(`[ext-pack-route] name=${routeName} packId=${reqPackId} sessionless outcome=error(${status}) durationMs=${Date.now() - start}: ${message}`); + json({ error: message }, status); + } + return; + } + // NOTE: the C2 session WRITE (`host.session.postMessage`) is intentionally NOT an // HTTP endpoint. It is driven over the TRUSTED session WebSocket // (`ext_session_post` in src/server/ws/handler.ts) so that no capturable session @@ -6823,6 +7617,57 @@ async function handleApiRoute( return ctxs; }; + // ── Managed-runtime activation/consent wiring (P3) ───────── + // Resolve a pack's SERVER-DERIVED packId + its runtime contributions + the + // effective deployment config carried by its providers, so the supervisor + // (start/stop/down) can be addressed by {packId, runtimeId}. Mirrors + // buildActivationCatalogue's on-disk entry resolution (works for built-in + // first-party packs too). Returns null when the pack is not resolvable. + const resolvePackRuntimeContext = ( + scope: InstallScope, + projectBase: string | undefined, + store: PackOrderStore, + packName: string, + ): { packId: string; runtimes: Array<{ id: string; listName: string; manifest: Record }>; deploymentConfig: Record; hasDeploymentSurface: boolean } | null => { + const base = scope === "server" ? getProjectRoot() : scope === "global-user" ? os.homedir() : projectBase; + if (base === undefined) return null; + const entries = scopeMarketPackEntries(scope as PackScope, base, store.getPackOrder(scope)); + let entry = entries.find((e) => e.manifest?.name === packName); + if ((!entry || !entry.manifest) && scope === "server") { + entry = builtinFirstPartyPackEntries(resolveBuiltinPacksDir()).find((e) => e.manifest?.name === packName); + } + if (!entry || !entry.manifest) return null; + const packId = packIdFromRoot(entry.path); + if (!packId) return null; + let contribs; + try { contribs = loadPackContributions(entry.path, entry.manifest); } + catch { return { packId, runtimes: [], deploymentConfig: {}, hasDeploymentSurface: false }; } + // Effective deployment config = each provider's FLAT schema defaults + // (ProviderContribution.config) overlaid with its persisted store config. + // Hindsight's `memory` provider carries the deployment mode/dataDir/etc. + const deploymentConfig: Record = {}; + let hasDeploymentSurface = false; + for (const p of contribs.providers) { + const merged: Record = { ...(p.config ?? {}) }; + const persisted = getPackStore().getSync>(packId, providerConfigStoreKey(p.id)); + if (persisted && typeof persisted === "object") Object.assign(merged, persisted); + Object.assign(deploymentConfig, merged); + if (providerCarriesDeploymentMode(p, merged)) hasDeploymentSurface = true; + } + // `hasDeploymentSurface` = the pack exposes a provider whose config ACTUALLY + // carries the deployment mode (external/managed/…). A runtime-only pack with NO + // provider — OR a pack whose only provider has no deployment mode — has no + // external/managed concept, so its `on-enable` runtime starts in the runtime's + // default mode rather than being suppressed by the external-default start plan + // (mirrors the REST start path's no-surface fallback so activation and + // `/api/pack-runtimes/:id/start` never diverge). + return { packId, runtimes: contribs.runtimes.map((r) => ({ id: r.id, listName: r.listName, manifest: r.manifest })), deploymentConfig, hasDeploymentSurface }; + }; + + // Deployment-mode → runtime start plan mapping is the module-level + // resolveRuntimeStartPlan() (the SINGLE source of truth shared with the + // /api/pack-runtimes/:id/{start,restart} REST routes — see finding #2). + // ── Sources ─────────────────────────────────────────────── // GET /api/marketplace/sources if (url.pathname === "/api/marketplace/sources" && req.method === "GET") { @@ -6962,6 +7807,44 @@ async function handleApiRoute( } const st = resolveScopeTarget(scope, body?.projectId); if (!st.ok) { json({ error: st.error }, st.status); return; } + // P3 — tear down this pack's managed runtimes BEFORE removing it, preserving + // bind-mounted data (no `-v`, no state removal). A missing Docker install is + // tolerated (down returns a docker-unavailable STATUS, never throws), so an + // uninstall on a Docker-less host still proceeds. A REAL teardown failure (down + // throws) is reported and the uninstall is ABORTED — never silently swallowed. + if (packRuntimeSupervisor) { + const teardownFailures: string[] = []; + try { + const rtCtx = resolvePackRuntimeContext(scope, st.target.projectBase, st.target.store, body.packName); + if (rtCtx && rtCtx.runtimes.length > 0) { + const projectId = scope === "project" ? (body?.projectId as string | undefined) : undefined; + // Tear down EVERY runtime contribution unconditionally — do NOT gate on the + // CURRENT saved deployment mode (resolveRuntimeStartPlan). A pack started in a + // managed mode and later reconfigured to `external` would otherwise skip + // teardown and leak its still-running containers. `down` is read-only/minimal + // and idempotent (it never resolves start-only inputs like + // HINDSIGHT_API_LLM_API_KEY, reuses an already-rendered .env only when one + // exists, and maps a missing Docker install to a docker-unavailable STATUS + // rather than throwing), so calling it for an external-only never-started + // runtime is a harmless no-op (`compose down` on an absent project exits 0). + for (const rc of rtCtx.runtimes) { + try { + await packRuntimeSupervisor.down(rtCtx.packId, rc.id, { projectId, volumes: false, removeState: false }); + } catch (err) { + teardownFailures.push(`${rtCtx.packId}:${rc.id}: ${(err as Error)?.message ?? String(err)}`); + } + } + } + } catch (err) { + // Resolving the pack's runtime context failed (e.g. the pack is no longer + // resolvable on disk) — there is nothing to tear down; proceed. + console.warn(`[pack-runtimes] uninstall runtime teardown skipped: ${(err as Error)?.message ?? err}`); + } + if (teardownFailures.length > 0) { + json({ error: "runtime teardown failed; pack not uninstalled", details: teardownFailures }, 502); + return; + } + } try { installer.uninstallPack({ packName: body.packName, scope, projectBase: st.target.projectBase, packOrderStore: st.target.store }); invalidateResolverCaches(); @@ -6979,6 +7862,11 @@ async function handleApiRoute( const builtinRows = builtinFirstPartyPackEntries(resolveBuiltinPacksDir()).map((e) => ({ scope: "server" as InstallScope, packName: e.manifest!.name, + // Structural id (the shipped `market-packs/` segment) the + // runtime/panel APIs key by — can DIVERGE from the manifest name for a + // built-in whose directory differs. The UI addresses runtime REST with + // this, not `packName`. + packId: packIdFromRoot(e.path), manifest: e.manifest!, meta: e.meta, status: "ok" as const, @@ -6987,6 +7875,12 @@ async function handleApiRoute( // "update available" (they update with the app upgrade, §4.2). updateAvailable: false, sourceStatus: "ok" as const, + // Default-disabled built-in packs (manifest `defaultDisabled: true`, e.g. + // Hindsight) ship dormant. Surface BOTH a stable structural flag + // (`defaultDisabled`) and the UI intent alias (`requiresGuidedSetup`) so the + // Marketplace can launch the guided setup wizard when the user enables it. + defaultDisabled: e.manifest!.defaultDisabled === true, + requiresGuidedSetup: e.manifest!.defaultDisabled === true, })); json({ installed: [...builtinRows, ...installer.listInstalled(allContexts(projectId))] }); } catch (err) { jsonError(500, err); } @@ -7139,9 +8033,177 @@ async function handleApiRoute( workflows: normaliseKind("workflows", new Set(catalogue.workflows ?? [])), }; const cfgStore = st.target.store as unknown as ProjectConfigStore; - cfgStore.setPackActivation(scope as PackOrderScope, packName, normalized); + const prevActivation = cfgStore.getPackActivation(scope as PackOrderScope, packName); + const prevDisabledRuntimes = new Set(prevActivation.runtimes ?? []); + + // P3 — managed-runtime activation side effects. Enabling a + // `startPolicy: on-enable` runtime (disabled → enabled) IS the explicit + // user start action; disabling (enabled → disabled) stops it. The external + // (non-Docker) deployment mode never starts a container. Toggling any other + // entity — or a pack with no runtimes — is inert here, so install/update/ + // list/status never start Docker. + // + // CRITICAL ordering: the Docker side effects run BEFORE the activation state + // is persisted, and a side effect that MATTERS (start/stop throwing, or a + // start that fails to come up) aborts the whole PUT WITHOUT persisting — so + // Bobbit never records "enabled"/"disabled" while Docker did the opposite. A + // graceful `docker-unavailable` status is TOLERATED (there is nothing to + // start/stop on a Docker-less host; the provider is defensive and the toggle + // is just metadata), so it persists and is reported, not treated as a hard + // failure. Stop is best-effort: only a thrown stop blocks a disable. + const runtimeStatuses: Array> = []; + const sideEffectFailures: string[] = []; + if (packRuntimeSupervisor && (catalogue.runtimes?.length ?? 0) > 0) { + const nextDisabledRuntimes = new Set(normalized.runtimes); + const rtCtx = resolvePackRuntimeContext(scope, st.target.projectBase, st.target.store, packName); + if (rtCtx && rtCtx.runtimes.length > 0) { + const projectId = scope === "project" ? (body?.projectId as string | undefined) : undefined; + const plan = resolveRuntimeStartPlan(rtCtx.deploymentConfig); + // A runtime-only pack with NO provider deployment-config surface has no + // external/managed concept, so resolveRuntimeStartPlan({}) defaults to + // external (start:false) and would wrongly suppress its `on-enable` start. + // Mirror the REST start path's no-surface fallback: enabling such a runtime + // starts it in the runtime's DEFAULT mode (mode undefined ⇒ supervisor picks + // the manifest default). When a deployment surface exists, honour plan.start. + const startWhenEnabled = plan.start || !rtCtx.hasDeploymentSurface; + const startMode = rtCtx.hasDeploymentSurface ? plan.mode : undefined; + for (const rc of rtCtx.runtimes) { + const ref = rc.listName; + const wasDisabled = prevDisabledRuntimes.has(ref); + const nowDisabled = nextDisabledRuntimes.has(ref); + const policy = readRuntimeStartPolicy(rc.manifest); + try { + if (wasDisabled && !nowDisabled) { + // disabled → enabled: explicit enable. Only `on-enable` runtimes + // auto-start, and only when the deployment mode is a managed + // (Docker) mode — external mode avoids the Docker start entirely. + // A provider-less runtime pack has no such gate (startWhenEnabled). + if (policy === "on-enable" && startWhenEnabled) { + const status = await packRuntimeSupervisor.start(rtCtx.packId, rc.id, { projectId, mode: startMode, config: plan.config }); + runtimeStatuses.push({ ...status, id: encodePackRuntimeId(status.packId, status.runtimeId) }); + // A managed enable that does not come up running (and is not a + // tolerated docker-unavailable) is a real failure: don't persist + // "enabled" while the container is unhealthy/down. + if (status.status !== "running" && status.status !== "starting" && status.status !== "docker-unavailable") { + sideEffectFailures.push(`${rtCtx.packId}:${rc.id} failed to start (${status.status}${status.message ? `: ${status.message}` : ""})`); + } + } + } else if (!wasDisabled && nowDisabled) { + // enabled → disabled: stop the managed container UNCONDITIONALLY — do NOT + // gate on the CURRENT saved deployment mode (plan.start). A runtime started + // in a managed mode and later reconfigured to `external` would otherwise + // skip the stop and leak its still-running container. `stop` is + // read-only/minimal and idempotent: it never resolves start-only inputs + // (e.g. HINDSIGHT_API_LLM_API_KEY), reuses an already-rendered .env only + // when one exists, and maps a missing Docker install to a + // docker-unavailable STATUS rather than throwing — so calling it for an + // external-only never-started runtime is a harmless no-op (`compose stop` + // on an absent project exits 0) and never 502s the disable. + const status = await packRuntimeSupervisor.stop(rtCtx.packId, rc.id, { projectId }); + runtimeStatuses.push({ ...status, id: encodePackRuntimeId(status.packId, status.runtimeId) }); + } + } catch (err) { + // A thrown start/stop (e.g. compose up/stop exploded) is a hard + // failure: abort the PUT so persisted state matches Docker reality. + runtimeStatuses.push({ + id: encodePackRuntimeId(rtCtx.packId, rc.id), + packId: rtCtx.packId, + runtimeId: rc.id, + status: "error", + message: (err as Error)?.message ?? String(err), + }); + sideEffectFailures.push(`${rtCtx.packId}:${rc.id}: ${(err as Error)?.message ?? String(err)}`); + } + } + } + } + + // A side effect that matters failed → do NOT persist (state is unchanged) and + // surface the failure with the prior activation so the client/UI reverts the + // toggle instead of believing the change took effect. + if (sideEffectFailures.length > 0) { + json({ + scope, + packName, + catalogue, + disabled: prevActivation, + runtimes: runtimeStatuses, + error: `runtime activation failed: ${sideEffectFailures.join("; ")}`, + }, 502); + return; + } + + // Default-disabled built-in packs (e.g. Hindsight) persist only DEVIATIONS + // from the pack's current default, keeping the dormancy invariant byte-clean + // and giving a deterministic way to return to the default (no stored record, + // no marker). The default itself depends on whether the pack is "already + // configured" (live-setup rule): configured ⇒ enabled ({}), else ⇒ all-disabled. + // • request == default → clear the stored record + drop the marker + // • request all-enabled, NOT default → explicit ENABLE: drop the record, SET the + // force-enable marker (an empty record is + // indistinguishable from "never touched", so + // the marker is what makes the enable stick) + // • anything else → persist the record verbatim (explicit + // per-entity / disable choice wins), drop marker + const ddInfo = scope === "server" ? getDefaultDisabledInfo(packName, cfgStore) : null; + if (ddInfo) { + let configured = false; + for (const pid of ddInfo.providerIds) { + if (isProviderConfigConfigured(getPackStore().getSync>(ddInfo.packId, providerConfigStoreKey(pid)))) { configured = true; break; } + } + const defaultDisabled: DisabledRefs = configured ? {} : ddInfo.allDisabled; + const nowAllEnabled = Object.keys(normalized).every((k) => (normalized as Record)[k].length === 0); + const marker = readForceEnabledPacks(cfgStore); + if (disabledRefsEqual(normalized, defaultDisabled)) { + cfgStore.setPackActivation(scope as PackOrderScope, packName, {}); + marker.delete(packName); + } else if (nowAllEnabled) { + // all-enabled but the default is disabled (pack not configured) → explicit enable. + cfgStore.setPackActivation(scope as PackOrderScope, packName, {}); + marker.add(packName); + } else { + cfgStore.setPackActivation(scope as PackOrderScope, packName, normalized); + marker.delete(packName); + } + writeForceEnabledPacks(cfgStore, marker); + } else { + cfgStore.setPackActivation(scope as PackOrderScope, packName, normalized); + } invalidateResolverCaches(); - json({ scope, packName, catalogue, disabled: cfgStore.getPackActivation(scope as PackOrderScope, packName) }); + + const activationResponse: Record = { scope, packName, catalogue, disabled: cfgStore.getPackActivation(scope as PackOrderScope, packName) }; + if (runtimeStatuses.length > 0) activationResponse.runtimes = runtimeStatuses; + json(activationResponse); + return; + } + + // ── purge a managed runtime (P3 explicit purge) ─────────── + // POST /api/marketplace/purge-runtime { packName, scope, runtimeId, projectId? } + // `compose down -v` + remove supervisor-owned runtime state (rendered env, + // persisted generated secrets + allocated ports). Bind-mounted DATA is + // preserved by the supervisor — only Docker volumes + bookkeeping are removed. + if (url.pathname === "/api/marketplace/purge-runtime" && req.method === "POST") { + const body = (await readBody(req)) as any; + const scope = parseScope(body?.scope); + if (!scope) { json({ error: "invalid scope" }, 400); return; } + if (typeof body?.packName !== "string" || !body.packName) { json({ error: "packName is required" }, 400); return; } + if (typeof body?.runtimeId !== "string" || !body.runtimeId) { json({ error: "runtimeId is required" }, 400); return; } + if (!packRuntimeSupervisor) { json({ error: "pack runtime supervisor unavailable" }, 503); return; } + const st = resolveScopeTarget(scope, body?.projectId); + if (!st.ok) { json({ error: st.error }, st.status); return; } + const rtCtx = resolvePackRuntimeContext(scope, st.target.projectBase, st.target.store, body.packName); + if (!rtCtx) { json({ error: "pack not installed at this scope" }, 404); return; } + const rc = rtCtx.runtimes.find((r) => r.id === body.runtimeId || r.listName === body.runtimeId); + if (!rc) { json({ error: `unknown runtime ${body.runtimeId}` }, 404); return; } + const projectId = scope === "project" ? (body?.projectId as string | undefined) : undefined; + try { + const status = await packRuntimeSupervisor.down(rtCtx.packId, rc.id, { projectId, volumes: true, removeState: true }); + json({ ...status, id: encodePackRuntimeId(status.packId, status.runtimeId) }); + } catch (err) { + if (err instanceof PackRuntimeNotFoundError) { jsonError(404, err); return; } + if (err instanceof PackRuntimeBadRequestError) { jsonError(400, err); return; } + jsonError(500, err); + } return; } @@ -14059,15 +15121,22 @@ export function bodyLimitExceeded( return Number.isFinite(len) && len > maxBytes; } -export function readBody( +/** + * Read the raw request body as text. Resolves the decoded string for a complete + * body (the empty string when no body was sent), or `null` when the body was + * oversized/aborted/errored. Unlike {@link readBody} it does NOT attempt to + * JSON-parse, so callers can distinguish an EMPTY body (valid — treat as `{}`) + * from a NON-EMPTY but malformed-JSON body (client error → 400). + */ +export function readBodyText( req: http.IncomingMessage, maxBytes: number = MAX_REQUEST_BODY_BYTES, -): Promise { +): Promise { return new Promise((resolve) => { const chunks: Buffer[] = []; let total = 0; let settled = false; - const finish = (value: any): void => { + const finish = (value: string | null): void => { if (settled) return; settled = true; resolve(value); @@ -14076,12 +15145,11 @@ export function readBody( if (settled) return; total += chunk.length; if (total > maxBytes) { - // Oversized body: reject BEFORE Buffer.concat()/JSON.parse() so a - // huge payload is never fully materialised in memory. Drop buffered - // chunks, tear down the stream, and resolve null — handlers treat a - // null body as a malformed request (400); the request-handler's - // Content-Length precheck returns a definitive 413 for the common - // case where the length is declared up front. + // Oversized body: reject BEFORE Buffer.concat() so a huge payload is + // never fully materialised in memory. Drop buffered chunks, tear down + // the stream, and resolve null — handlers treat a null body as a + // malformed request (400); the request-handler's Content-Length + // precheck returns a definitive 413 when the length is declared up front. chunks.length = 0; try { req.destroy(); } catch { /* best-effort */ } finish(null); @@ -14089,18 +15157,29 @@ export function readBody( } chunks.push(chunk); }); - req.on("end", () => { - try { - finish(JSON.parse(Buffer.concat(chunks).toString())); - } catch { - finish(null); - } - }); + req.on("end", () => finish(Buffer.concat(chunks).toString())); req.on("error", () => finish(null)); req.on("aborted", () => finish(null)); }); } +export function readBody( + req: http.IncomingMessage, + maxBytes: number = MAX_REQUEST_BODY_BYTES, +): Promise { + return readBodyText(req, maxBytes).then((text) => { + // Preserve the historical contract: a null (oversized/aborted) OR + // unparseable/empty body resolves to null. Callers that must distinguish an + // empty body from a malformed one read the raw text via readBodyText instead. + if (text === null) return null; + try { + return JSON.parse(text); + } catch { + return null; + } + }); +} + const MIME_TYPES: Record = { ".html": "text/html", ".js": "application/javascript", diff --git a/tests/client-session-write.spec.ts b/tests/client-session-write.spec.ts index 5f2c4d391..a63e28673 100644 --- a/tests/client-session-write.spec.ts +++ b/tests/client-session-write.spec.ts @@ -23,11 +23,34 @@ * file:// fixture loads it, and we drive helpers via window globals. */ import { test, expect } from "@playwright/test"; -import { execSync } from "node:child_process"; import { createHash } from "node:crypto"; +import esbuild from "esbuild"; import fs from "node:fs"; import path from "node:path"; +// esbuild's direct outfile write is not atomic. With fullyParallel enabled the +// four tests in this file can split across workers, so multiple beforeAll hooks +// may cold-build the SHARED bundle concurrently — a sibling page then loads a +// partial bundle and waits forever for `window.__ready` (TimeoutError in +// gotoAndWait). Build to a unique temp path, then atomically replace the shared +// bundle. Mirrors marketplace-active-project.spec.ts. +async function renameWithRetry(src: string, dest: string): Promise { + const deadline = Date.now() + 5_000; + let lastErr: unknown; + while (Date.now() < deadline) { + try { + fs.renameSync(src, dest); + return; + } catch (err) { + lastErr = err; + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw err; + await new Promise((r) => setTimeout(r, 100)); + } + } + throw lastErr; +} + const FIXTURE = path.resolve("tests/fixtures/client-session-write.html"); const BUNDLE = path.resolve("tests/fixtures/client-session-write-bundle.js"); const ENTRY = path.resolve("tests/fixtures/client-session-write-entry.ts"); @@ -37,7 +60,7 @@ const BRIDGE_SRC = path.resolve("src/app/session-write-bridge.ts"); const BUS_SRC = path.resolve("src/app/session-event-bus.ts"); const SHARED_SRC = path.resolve("src/shared/extension-host/host-api.ts"); -test.beforeAll(() => { +test.beforeAll(async () => { const entryMtime = Math.max( fs.statSync(ENTRY).mtimeMs, fs.statSync(HOST_SRC).mtimeMs, @@ -49,17 +72,24 @@ test.beforeAll(() => { const bundleExists = fs.existsSync(BUNDLE); const bundleStale = bundleExists && fs.statSync(BUNDLE).mtimeMs < entryMtime; if (!bundleExists || bundleStale) { - execSync( - [ - `npx esbuild ${ENTRY}`, - "--bundle --format=iife --target=es2022", - `--outfile=${BUNDLE}`, - "--tsconfig=tsconfig.web.json", - "--alias:pdfjs-dist=./tests/fixtures/empty-shim", - "--define:import.meta.url='\"http://localhost/\"'", - ].join(" "), - { stdio: "pipe" }, - ); + const tmpDir = fs.mkdtempSync(path.join(path.dirname(BUNDLE), ".bundle-tmp-")); + const tmpOut = path.join(tmpDir, path.basename(BUNDLE)); + try { + await esbuild.build({ + entryPoints: [ENTRY], + bundle: true, + format: "iife", + target: "es2022", + outfile: tmpOut, + tsconfig: "tsconfig.web.json", + alias: { "pdfjs-dist": "./tests/fixtures/empty-shim" }, + define: { "import.meta.url": '"http://localhost/"' }, + loader: { ".ts": "ts" }, + }); + await renameWithRetry(tmpOut, BUNDLE); + } finally { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } } }); diff --git a/tests/e2e/hindsight-agent-tools.spec.ts b/tests/e2e/hindsight-agent-tools.spec.ts new file mode 100644 index 000000000..438d6c63c --- /dev/null +++ b/tests/e2e/hindsight-agent-tools.spec.ts @@ -0,0 +1,606 @@ +/** + * API E2E — P5 Hindsight agent tools (`hindsight_recall`, `hindsight_retain`, + * `hindsight_reflect`, `hindsight_retain_outcome`, `hindsight_invalidate`). + * + * Verifies the three pack-owned agent tools round-trip through the REAL + * tool-activation + authorization path to the in-process Hindsight STUB + * (tests/e2e/hindsight-stub.mjs), and that disabling the pack removes the tools + * from a project's resolved tool list. + * + * ── What "the real tool activation path" means here ────────────────────────── + * The shipped tools are `bobbit-extension` tools whose handler + * (market-packs/hindsight/tools/hindsight/extension.ts) does exactly two HTTP + * calls per invocation: + * 1. POST /api/ext/surface-token { sessionId, tool } → mint a tool-bound + * SERVER-MINTED surface token (tool-guard: tool ∈ allowedTools + own session + * + tool resolves to a market pack). + * 2. POST /api/ext/route/ + * { sessionId, surfaceToken, init:{ method:"POST", body } } → dispatch the + * pack's route in the confined worker, which owns config merge, bank + * resolution (default `bobbit`), external-mode handling, dormancy, and the + * scope→tag mapping, then calls the Hindsight client → the stub. + * + * The in-process mock agent cannot LOAD/execute a `pi.registerTool` extension + * (it has no real LLM and no extension host for agent tools), so this spec drives + * the SAME two endpoints the tool's handler drives — i.e. it exercises the real + * surface-token mint, the tool-guard, the route registry, the confined-worker + * route dispatch, the inlined REST client, and the stub. This is the faithful + * agent-tool round-trip minus only the thin `execute()` text-formatting wrapper. + * (Mirrors how tests/e2e/ui/pr-walkthrough-pack.spec.ts exercises pack routes via + * a minted surface token rather than re-importing the route functions.) + * + * Pack layering + config seeding mirror the sibling hindsight-external.spec.ts: + * the pack is installed as a SERVER-scope market pack (NOT via + * BOBBIT_BUILTIN_PACKS_DIR, which would clobber sibling specs sharing the + * worker-scoped in-process gateway) and provider config is seeded into the + * pack-scoped store BEFORE use. Pointing `externalUrl` at the stub activates the + * route's data plane (`isActive` == `isConfigured` in external mode). + * + * The whole suite SKIPS cleanly until the P5 tool descriptors land on the branch, + * so it never red-bars the e2e phase before the implementation is merged. + */ +import { test as base, expect } from "./in-process-harness.js"; +import { + apiFetch, + createSession, + deleteSession, + defaultProjectId, +} from "./e2e-setup.js"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); + +const PACK_NAME = "hindsight"; +const PACK_SRC = path.resolve(__dirname, "..", "..", "market-packs", PACK_NAME); +const STUB_PATH = path.resolve(__dirname, "hindsight-stub.mjs"); +const TOOLS_DIR = path.join(PACK_SRC, "tools", "hindsight"); + +// The pack-store key the loader/route persist provider config under. Mirrors +// CONFIG_KEY in market-packs/hindsight/src/shared.ts (== providerConfigStoreKey +// ("memory")), which loadEffectiveConfig() reads inside the route. +const CONFIG_STORE_KEY = "provider-config:memory"; + +// The P5 agent tools. +const RECALL = "hindsight_recall"; +const RETAIN = "hindsight_retain"; +const REFLECT = "hindsight_reflect"; +const RETAIN_OUTCOME = "hindsight_retain_outcome"; +const INVALIDATE = "hindsight_invalidate"; +const HINDSIGHT_TOOLS = [RECALL, RETAIN, REFLECT, RETAIN_OUTCOME, INVALIDATE] as const; + +// Gate the suite on the P5 tool descriptors being present so the e2e phase stays +// green until the pack tools are merged. When the descriptors land they bring the +// rebuilt lib/routes.mjs (with the retain scope→tag mapping) alongside them. +const DEPS_READY = + fs.existsSync(path.join(PACK_SRC, "pack.yaml")) && + fs.existsSync(path.join(PACK_SRC, "lib", "routes.mjs")) && + fs.existsSync(path.join(PACK_SRC, "lib", "provider.mjs")) && + fs.existsSync(STUB_PATH) && + HINDSIGHT_TOOLS.every((n) => fs.existsSync(path.join(TOOLS_DIR, `${n}.yaml`))); + +// New v2 routes are produced by the generated pack bundle. This task owns source +// routes/tools only; keep the focused v2 cases dormant until the bundle/stub task +// lands, while still defining the exact E2E coverage expected after generation. +const V2_ROUTES_READY = DEPS_READY && fs.readFileSync(path.join(PACK_SRC, "lib", "routes.mjs"), "utf-8").includes("retain_outcome"); + +const test = base; +const describe = DEPS_READY ? test.describe : test.describe.skip; + +// ── stub typing (the .mjs is untyped; describe its shape locally) ──────────── +interface RetainedItem { content: string; tags: string[]; async: boolean; document_id?: string; update_mode?: string; entities?: Array<{ text: string; type?: string }>; observation_scopes?: string[][]; timestamp?: string } +interface RecordedCall { method: string; path: string; bank?: string; namespace?: string; body?: any } +interface HindsightStub { + url: string; + calls: RecordedCall[]; + setHealthy(ok: boolean): void; + seedMemories(bank: string, mem: { text: string; id?: string; score?: number; tags?: string[] }[]): void; + retained(bank?: string): RetainedItem[]; + close(): Promise; +} + +async function startStub(): Promise { + const mod = await import(STUB_PATH as string); + const start = mod.startHindsightStub ?? mod.default; + return start({ port: 0 }) as Promise; +} + +function writeMeta(packDir: string): void { + fs.writeFileSync( + path.join(packDir, ".pack-meta.yaml"), + [ + "sourceUrl: e2e", + "sourceRef: local", + "commit: test", + `packName: ${PACK_NAME}`, + "version: 1.0.0", + "installedAt: '2026-01-01T00:00:00.000Z'", + "updatedAt: '2026-01-01T00:00:00.000Z'", + "scope: server", + ].join("\n") + "\n", + "utf-8", + ); +} + +function installPack(bobbitDir: string): string { + const packDir = path.join(bobbitDir, ".bobbit", "config", "market-packs", PACK_NAME); + fs.rmSync(packDir, { recursive: true, force: true }); + fs.cpSync(PACK_SRC, packDir, { recursive: true }); + writeMeta(packDir); + return packDir; +} + +/** Percent-encode every non-alphanumeric byte — mirrors pack-store.ts::encodeKey + * so config lands at the exact path the loader/route read. */ +function encodeStoreKey(key: string): string { + const bytes = Buffer.from(key, "utf8"); + let out = ""; + for (const b of bytes) { + const isAlnum = (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a); + out += isAlnum ? String.fromCharCode(b) : `%${b.toString(16).toUpperCase().padStart(2, "0")}`; + } + return out; +} + +/** Seed (or clear) the Hindsight provider config in the pack-scoped store. The + * route's loadEffectiveConfig() overlays this over the yaml defaults; an + * `externalUrl` makes the route's `isActive` gate pass (external mode). */ +function seedConfig(bobbitDir: string, config: Record | null): void { + const dir = path.join(bobbitDir, "state", "ext-store", PACK_NAME); + const file = path.join(dir, `${encodeStoreKey(CONFIG_STORE_KEY)}.json`); + if (config === null) { + fs.rmSync(file, { force: true }); + return; + } + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ v: 1, value: config }), "utf-8"); +} + +function externalConfig(stubUrl: string, over: Record = {}): Record { + return { + mode: "external", + externalUrl: stubUrl, + bank: "bobbit", + namespace: "default", + recallScope: "all", + autoRecall: true, + autoRetain: true, + recallBudget: 1200, + timeoutMs: 1500, + ...over, + }; +} + +/** Replace the pack's disabled-entity refs at server scope (the install scope). + * An all-empty payload clears the override (everything enabled). */ +async function setPackActivation(disabled: Record): Promise { + const resp = await apiFetch("/api/marketplace/pack-activation", { + method: "PUT", + body: JSON.stringify({ scope: "server", packName: PACK_NAME, disabled }), + }); + expect(resp.status, await resp.text().catch(() => "")).toBe(200); +} + +const ALL_ENABLED = { roles: [], tools: [], skills: [], entrypoints: [], providers: [] }; + +/** The set of agent-tool names resolved for a project (== the tools a session in + * that project would be offered). */ +async function projectToolNames(projectId: string): Promise> { + const resp = await apiFetch(`/api/tools?projectId=${encodeURIComponent(projectId)}`); + expect(resp.status).toBe(200); + const body = await resp.json(); + return new Set((body.tools as Array<{ name?: string }>).map((t) => t.name).filter(Boolean) as string[]); +} + +/** Mint a tool-bound surface token (raw — caller inspects status). */ +async function mintToolToken(sessionId: string, tool: string): Promise { + return apiFetch("/api/ext/surface-token", { + method: "POST", + headers: { "x-bobbit-session-id": sessionId }, + body: JSON.stringify({ sessionId, tool }), + }); +} + +interface RouteResult { status: number; body: any } + +/** Drive the EXACT surface-token + route round-trip the tool's extension handler + * drives: mint a tool-bound token, then POST the pack route with it. */ +async function invokeTool( + sessionId: string, + tool: string, + routeName: string, + routeBody: Record, +): Promise { + const mintResp = await mintToolToken(sessionId, tool); + const mintText = await mintResp.text(); + expect(mintResp.status, `surface-token mint failed: ${mintText}`).toBe(200); + const surfaceToken = (JSON.parse(mintText) as { token?: string }).token as string; + expect(surfaceToken).toBeTruthy(); + const resp = await apiFetch(`/api/ext/route/${encodeURIComponent(routeName)}`, { + method: "POST", + headers: { "x-bobbit-session-id": sessionId }, + body: JSON.stringify({ sessionId, surfaceToken, init: { method: "POST", body: routeBody } }), + }); + return { status: resp.status, body: resp.status === 200 ? await resp.json() : await resp.text() }; +} + +function recallCalls(stub: HindsightStub, sinceIdx: number): RecordedCall[] { + return stub.calls.slice(sinceIdx).filter((c) => c.method === "POST" && /\/memories\/recall$/.test(c.path)); +} +function reflectCalls(stub: HindsightStub, sinceIdx: number): RecordedCall[] { + return stub.calls.slice(sinceIdx).filter((c) => c.method === "POST" && /\/reflect$/.test(c.path)); +} + +describe.configure({ mode: "serial" }); + +describe("hindsight agent tools — recall/retain/reflect round-trip (stub)", () => { + const sessions: string[] = []; + let packDir: string; + let bobbitDir: string; + let stub: HindsightStub; + let projectId: string; + + async function newSession(): Promise { + const id = await createSession(); + sessions.push(id); + return id; + } + + test.beforeAll(async ({ gateway }) => { + bobbitDir = gateway.bobbitDir; + packDir = installPack(bobbitDir); + stub = await startStub(); + projectId = (await defaultProjectId())!; + expect(projectId, "harness default project id resolves").toBeTruthy(); + await setPackActivation(ALL_ENABLED); + }); + + test.afterAll(async () => { + await setPackActivation(ALL_ENABLED).catch(() => {}); + seedConfig(bobbitDir, null); + if (stub) await stub.close().catch(() => {}); + if (packDir) fs.rmSync(packDir, { recursive: true, force: true }); + }); + + test.afterEach(async () => { + await setPackActivation(ALL_ENABLED).catch(() => {}); + seedConfig(bobbitDir, null); + for (const id of sessions.splice(0)) await deleteSession(id).catch(() => {}); + }); + + test("recall maps scope to tag filters on the default `bobbit` bank", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); // bank: bobbit + const id = await newSession(); + + // scope:project → project: tag + tags_match:any on bank `bobbit`. + let mark = stub.calls.length; + const proj = await invokeTool(id, RECALL, "recall", { query: "how do we ship safely?", scope: "project" }); + expect(proj.status).toBe(200); + expect(proj.body.configured).toBe(true); + const projCalls = recallCalls(stub, mark); + expect(projCalls.length).toBe(1); + expect(projCalls[0].bank).toBe("bobbit"); + expect(projCalls[0].body?.tags).toEqual([`project:${projectId}`]); + expect(projCalls[0].body?.tags_match).toBe("any"); + + // scope:all → NO project tag filter on bank `bobbit`. + mark = stub.calls.length; + const all = await invokeTool(id, RECALL, "recall", { query: "how do we ship safely?", scope: "all" }); + expect(all.status).toBe(200); + expect(all.body.configured).toBe(true); + const allCalls = recallCalls(stub, mark); + expect(allCalls.length).toBe(1); + expect(allCalls[0].bank).toBe("bobbit"); + expect(allCalls[0].body?.tags).toBeUndefined(); + expect(allCalls[0].body?.tags_match).toBeUndefined(); + }); + + test("recall scope returns seeded project-tagged memories through the route", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); + stub.seedMemories("bobbit", [ + { text: "Risky rollouts always go behind a feature flag.", id: "m1", tags: [`project:${projectId}`] }, + ]); + const id = await newSession(); + const res = await invokeTool(id, RECALL, "recall", { query: "rollout policy", scope: "project" }); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(Array.isArray(res.body.memories)).toBe(true); + expect(res.body.memories.map((m: { text: string }) => m.text)).toContain( + "Risky rollouts always go behind a feature flag.", + ); + }); + + test("retain records kind:manual and a project tag when scoped to project", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); // bank: bobbit + const id = await newSession(); + + const before = stub.retained("bobbit").length; + const res = await invokeTool(id, RETAIN, "retain", { + content: "We migrated billing to the new queue.", + scope: "project", + sync: true, + }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.configured).toBe(true); + + const retained = stub.retained("bobbit"); + expect(retained.length).toBe(before + 1); + const item = retained[retained.length - 1]; + expect(item.content).toBe("We migrated billing to the new queue."); + expect(item.tags).toContain("kind:manual"); + expect(item.tags).toContain(`project:${projectId}`); + }); + + test("retain scope:all carries kind:manual but NO project tag", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); + const id = await newSession(); + + const before = stub.retained("bobbit").length; + const res = await invokeTool(id, RETAIN, "retain", { content: "Unscoped fact.", scope: "all", sync: true }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + + const retained = stub.retained("bobbit"); + expect(retained.length).toBe(before + 1); + const item = retained[retained.length - 1]; + expect(item.tags).toContain("kind:manual"); + expect(item.tags.some((t) => t.startsWith("project:"))).toBe(false); + }); + + test("retain enforces kind:manual — user-supplied tags cannot override it", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); + const id = await newSession(); + + const before = stub.retained("bobbit").length; + // A user tries to spoof the provenance marker via `tags: { kind: "spoofed" }`. + const res = await invokeTool(id, RETAIN, "retain", { + content: "Manual provenance is enforced.", + tags: { kind: "spoofed", topic: "billing" }, + scope: "all", + sync: true, + }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + + const retained = stub.retained("bobbit"); + expect(retained.length).toBe(before + 1); + const item = retained[retained.length - 1]; + // kind stays "manual"; the spoofed value is never persisted. + expect(item.tags).toContain("kind:manual"); + expect(item.tags).not.toContain("kind:spoofed"); + // Other user tags stay additive. + expect(item.tags).toContain("topic:billing"); + }); + + test("reflect runs over the resolved shared bank and returns synthesized text", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); // bank: bobbit + const id = await newSession(); + + const mark = stub.calls.length; + const res = await invokeTool(id, REFLECT, "reflect", { prompt: "what did we learn about billing?", scope: "all" }); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(typeof res.body.text).toBe("string"); + expect(res.body.text).toContain("what did we learn about billing?"); + + const calls = reflectCalls(stub, mark); + expect(calls.length).toBe(1); + expect(calls[0].bank).toBe("bobbit"); + }); + + test("reflect maps scope to tag filters on the shared bank (project filters; all does not)", async () => { + // P5 contract: reflect's `scope` maps to a TAG FILTER on the shared bank, just + // like recall — a project-scoped reflect must NOT reflect over the whole bank. + seedConfig(bobbitDir, externalConfig(stub.url)); // bank: bobbit + const id = await newSession(); + + // scope:project → project: tag + tags_match:any on bank `bobbit`. + let mark = stub.calls.length; + const proj = await invokeTool(id, REFLECT, "reflect", { prompt: "how do we ship safely?", scope: "project" }); + expect(proj.status).toBe(200); + const projCalls = reflectCalls(stub, mark); + expect(projCalls.length).toBe(1); + expect(projCalls[0].bank).toBe("bobbit"); + expect(projCalls[0].body?.tags).toEqual([`project:${projectId}`]); + expect(projCalls[0].body?.tags_match).toBe("any"); + + // scope:all → NO project tag filter (reflect over the whole bank). + mark = stub.calls.length; + const all = await invokeTool(id, REFLECT, "reflect", { prompt: "how do we ship safely?", scope: "all" }); + expect(all.status).toBe(200); + const allCalls = reflectCalls(stub, mark); + expect(allCalls.length).toBe(1); + expect(allCalls[0].bank).toBe("bobbit"); + expect(allCalls[0].body?.tags).toBeUndefined(); + expect(allCalls[0].body?.tags_match).toBeUndefined(); + }); + + test("v2 reflect passes responseSchema/factTypes/excludeMentalModels and returns structuredOutput", async () => { + test.skip(!V2_ROUTES_READY, "v2 generated routes/stub not present on this branch yet"); + seedConfig(bobbitDir, externalConfig(stub.url)); + const id = await newSession(); + const schema = { type: "object", properties: { answer: { type: "string" } } }; + const mark = stub.calls.length; + const res = await invokeTool(id, REFLECT, "reflect", { + prompt: "what did we learn?", + responseSchema: schema, + factTypes: ["observation"], + excludeMentalModels: true, + }); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(res.body.structuredOutput).toBeTruthy(); + const calls = reflectCalls(stub, mark); + expect(calls.length).toBe(1); + expect(calls[0].body?.response_schema).toEqual(schema); + expect(calls[0].body?.fact_types).toEqual(["observation"]); + expect(calls[0].body?.exclude_mental_models).toBe(true); + expect(calls[0].body?.query).toContain("Bobbit coding-agent memory reflection instructions"); + }); + + test("v2 retainOutcome route writes a stable replacement outcome digest", async () => { + test.skip(!V2_ROUTES_READY, "v2 generated routes/stub not present on this branch yet"); + seedConfig(bobbitDir, externalConfig(stub.url)); + const id = await newSession(); + const before = stub.retained("bobbit").length; + const res = await invokeTool(id, RETAIN_OUTCOME, "retain_outcome", { + content: "Completed billing queue migration.", + goalId: "g-outcome", + pr: 42, + files: ["src/billing.ts"], + components: ["billing"], + timestamp: "2026-06-21T00:00:00.000Z", + }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.documentId).toBe("outcome:g-outcome"); + const retained = stub.retained("bobbit"); + expect(retained.length).toBe(before + 1); + const item = retained[retained.length - 1]; + expect(item.document_id).toBe("outcome:g-outcome"); + expect(item.update_mode).toBe("replace"); + expect(item.tags).toContain("kind:outcome"); + expect(item.tags).toContain(`project:${projectId}`); + expect(item.tags).toContain("goal:g-outcome"); + expect(item.tags).toContain("pr:42"); + expect(item.observation_scopes).toEqual([[`project:${projectId}`]]); + expect(item.entities).toEqual([{ text: "src/billing.ts", type: "file" }, { text: "billing", type: "component" }]); + }); + + test("v2 invalidate route retires a seeded memory from later recall", async () => { + test.skip(!V2_ROUTES_READY, "v2 generated routes/stub not present on this branch yet"); + seedConfig(bobbitDir, externalConfig(stub.url)); + stub.seedMemories("bobbit", [{ id: "m-stale", text: "Old stale decision.", tags: [`project:${projectId}`] }]); + const id = await newSession(); + const inv = await invokeTool(id, INVALIDATE, "invalidate", { id: "m-stale", reason: "Superseded by new decision." }); + expect(inv.status).toBe(200); + expect(inv.body.ok).toBe(true); + const recall = await invokeTool(id, RECALL, "recall", { query: "stale decision", scope: "project" }); + expect(recall.status).toBe(200); + expect((recall.body.memories as Array<{ id?: string }>).some((m) => m.id === "m-stale")).toBe(false); + }); + + test("a configured custom bank flows through every route to the stub", async () => { + const CUSTOM_BANK = "custom-memory-bank"; + seedConfig(bobbitDir, externalConfig(stub.url, { bank: CUSTOM_BANK })); + const id = await newSession(); + + let mark = stub.calls.length; + const recall = await invokeTool(id, RECALL, "recall", { query: "where do memories live?", scope: "all" }); + expect(recall.status).toBe(200); + const rc = recallCalls(stub, mark); + expect(rc.length).toBe(1); + expect(rc[0].bank).toBe(CUSTOM_BANK); + + mark = stub.calls.length; + const reflect = await invokeTool(id, REFLECT, "reflect", { prompt: "summary", scope: "all" }); + expect(reflect.status).toBe(200); + const fc = reflectCalls(stub, mark); + expect(fc.length).toBe(1); + expect(fc[0].bank).toBe(CUSTOM_BANK); + + const before = stub.retained(CUSTOM_BANK).length; + const retain = await invokeTool(id, RETAIN, "retain", { content: "Bank routing works.", scope: "project", sync: true }); + expect(retain.status).toBe(200); + expect(retain.body.ok).toBe(true); + const retained = stub.retained(CUSTOM_BANK); + expect(retained.length).toBe(before + 1); + expect(retained[retained.length - 1].tags).toContain(`project:${projectId}`); + }); + + test("recall: an optional tags filter NARROWS a project recall (all_strict, no broadening)", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); + const id = await newSession(); + const mark = stub.calls.length; + const res = await invokeTool(id, RECALL, "recall", { query: "q", scope: "project", tags: { goal: "g9" } }); + expect(res.status).toBe(200); + const calls = recallCalls(stub, mark); + expect(calls.length).toBe(1); + expect((calls[0].body?.tags as string[]).slice().sort()).toEqual([`goal:g9`, `project:${projectId}`].sort()); + // all_strict ⇒ require project AND goal, exclude untagged/global + other projects + // (the optional tag NARROWS recall instead of broadening it via `any`). + expect(calls[0].body?.tags_match).toBe("all_strict"); + }); + + test("recall: an optional tags.project can NOT override the route-derived project", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); + const id = await newSession(); + const mark = stub.calls.length; + const res = await invokeTool(id, RECALL, "recall", { query: "q", scope: "project", tags: { project: "evil", goal: "g9" } }); + expect(res.status).toBe(200); + const calls = recallCalls(stub, mark); + expect(calls.length).toBe(1); + // The caller's project:evil is dropped; the session's real project tag wins. + expect((calls[0].body?.tags as string[]).slice().sort()).toEqual([`goal:g9`, `project:${projectId}`].sort()); + expect(calls[0].body?.tags_match).toBe("all_strict"); + }); + + test("reflect: an optional tags filter NARROWS a project reflect (all_strict, no broadening)", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); + const id = await newSession(); + const mark = stub.calls.length; + const res = await invokeTool(id, REFLECT, "reflect", { prompt: "p", scope: "project", tags: { topic: "auth" } }); + expect(res.status).toBe(200); + const calls = reflectCalls(stub, mark); + expect(calls.length).toBe(1); + expect((calls[0].body?.tags as string[]).slice().sort()).toEqual([`project:${projectId}`, "topic:auth"].sort()); + expect(calls[0].body?.tags_match).toBe("all_strict"); + }); + + test("recall/reflect descriptors expose a simple `tags` param but NOT a tag_groups param", () => { + const recallYaml = fs.readFileSync(path.join(TOOLS_DIR, "hindsight_recall.yaml"), "utf-8"); + const reflectYaml = fs.readFileSync(path.join(TOOLS_DIR, "hindsight_reflect.yaml"), "utf-8"); + for (const y of [recallYaml, reflectYaml]) { + const paramsLine = y.split("\n").find((l) => l.startsWith("params:")) ?? ""; + // The agent surface offers a flat `tags` filter; compound `tag_groups` is a + // direct data-plane escape hatch (mentioned in prose, never a tool param). + expect(paramsLine).toMatch(/tags\?/); + expect(paramsLine).not.toMatch(/tag_groups/); + } + }); + + test("the three tools resolve for a project session and mint tool-bound surface tokens", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); + const id = await newSession(); + + const names = await projectToolNames(projectId); + for (const t of HINDSIGHT_TOOLS) { + expect(names.has(t), `tool ${t} present in resolved tool list`).toBe(true); + } + // Each tool can mint a tool-bound surface token (the activation/auth path). + for (const t of HINDSIGHT_TOOLS) { + const resp = await mintToolToken(id, t); + expect(resp.status, `mint ${t}`).toBe(200); + expect((await resp.json()).token).toBeTruthy(); + } + }); + + test("disabling the pack tools removes them from a newly-created session's tool list", async () => { + seedConfig(bobbitDir, externalConfig(stub.url)); + + // Baseline: present. + const enabled = await projectToolNames(projectId); + for (const t of HINDSIGHT_TOOLS) expect(enabled.has(t)).toBe(true); + + // Disable the three pack tools at the install (server) scope. The project's + // resolved tool list (== a session-in-project's tool list) drops them. + await setPackActivation({ ...ALL_ENABLED, tools: [...HINDSIGHT_TOOLS] }); + + const id = await newSession(); // created AFTER the disable + const disabled = await projectToolNames(projectId); + for (const t of HINDSIGHT_TOOLS) { + expect(disabled.has(t), `tool ${t} absent after pack disable`).toBe(false); + } + // A disabled tool no longer resolves as a market-pack tool, so a tool-bound + // surface token cannot be minted (the activation gate is closed end-to-end). + const mint = await mintToolToken(id, RECALL); + expect(mint.status).toBe(403); + + // Re-enabling restores them. + await setPackActivation(ALL_ENABLED); + const reenabled = await projectToolNames(projectId); + for (const t of HINDSIGHT_TOOLS) expect(reenabled.has(t)).toBe(true); + }); +}); diff --git a/tests/e2e/hindsight-config-write.spec.ts b/tests/e2e/hindsight-config-write.spec.ts new file mode 100644 index 000000000..62691c4b2 --- /dev/null +++ b/tests/e2e/hindsight-config-write.spec.ts @@ -0,0 +1,209 @@ +/** + * API E2E — sessionless built-in pack-route CONFIG WRITE seam + * (`POST /api/ext/pack-route/:packId/config`). + * + * The Marketplace Configure button writes Hindsight config inline from `#/market`, + * where there is NO active chat session to mint a surface token. This seam is the + * GET seam's config-write sibling: admin-bearer + BUILT-IN pack only, ALLOWLISTED to + * the `config` route name. It validates + persists to the pack store and NEVER starts + * Docker. This spec pins: + * + * 1. POST config to the built-in `hindsight` pack persists, and a subsequent GET + * reflects the saved values (round-trip through the real route + pack store). + * 2. POST to a NON-config route name (e.g. `status`) is rejected 403 (not a general + * write seam). + * 3. CONFIG_INVALID — an invalid override returns the route's structured error. + * + * Runs against the BUILT-IN band (the seam restricts writes to built-in first-party + * packs), so it gates on the Hindsight contribution being served in this environment + * and skips cleanly otherwise. + */ +import { test as base, expect } from "./in-process-harness.js"; +import { apiFetch, defaultProjectId } from "./e2e-setup.js"; + +const test = base; + +const PACK = "hindsight"; +const CONFIG_KEY = "provider-config:memory"; +const PROJECT_CONFIG_PREFIX = "provider-config:memory:project:"; + +interface ContribMeta { packId: string; routeNames?: string[] } + +/** Whether the BUILT-IN Hindsight pack is served with its `config` route in this + * environment (dist not rebuilt / branches not merged ⇒ skip). */ +async function hindsightConfigRouteReady(): Promise { + const res = await apiFetch("/api/ext/contributions"); + if (!res.ok) return false; + const packs = ((await res.json()).packs ?? []) as ContribMeta[]; + const meta = packs.find((p) => p.packId === PACK); + return !!meta && !!meta.routeNames?.includes("config"); +} + +/** Reset the built-in pack's persisted config so writes here never leak into sibling + * specs sharing the worker-scoped in-process gateway. */ +async function resetConfig(): Promise { + const { getPackStore } = await import("../../dist/server/extension-host/pack-store.js"); + const store = getPackStore(); + await store.put(PACK, CONFIG_KEY, {}); + // Clear any per-project overlay for the default project so writes here never leak. + try { + const pid = await defaultProjectId(); + if (pid) await store.put(PACK, `${PROJECT_CONFIG_PREFIX}${pid}`, {}); + } catch { + /* best-effort */ + } +} + +test.describe.configure({ mode: "serial" }); + +test.describe("Hindsight built-in pack-route config write seam", () => { + let ready = false; + + test.beforeAll(async () => { + ready = await hindsightConfigRouteReady(); + }); + + test.afterEach(async () => { + if (ready) await resetConfig(); + }); + + test("POST config persists and a subsequent GET reflects the saved values", async () => { + test.skip(!ready, "Hindsight built-in config route not served in this environment"); + + const overrides = { + mode: "external", + externalUrl: "http://localhost:9177", + uiUrl: "http://localhost:19177/banks/bobbit?view=data", + bank: "e2e-write-bank", + namespace: "default", + }; + const writeRes = await apiFetch(`/api/ext/pack-route/${PACK}/config`, { + method: "POST", + body: JSON.stringify(overrides), + }); + expect(writeRes.status).toBe(200); + const written = await writeRes.json(); + expect(written.ok).toBe(true); + expect(written.configured).toBe(true); + expect(written.config.externalUrl).toBe(overrides.externalUrl); + expect(written.config.uiUrl).toBe(overrides.uiUrl); + expect(written.config.bank).toBe(overrides.bank); + + // A fresh GET over the same seam reflects the persisted values. + const readRes = await apiFetch(`/api/ext/pack-route/${PACK}/config`); + expect(readRes.status).toBe(200); + const read = await readRes.json(); + expect(read.config.externalUrl).toBe(overrides.externalUrl); + expect(read.config.uiUrl).toBe(overrides.uiUrl); + expect(read.config.bank).toBe(overrides.bank); + }); + + test("a partial write merges over stored config (does not clobber untouched fields)", async () => { + test.skip(!ready, "Hindsight built-in config route not served in this environment"); + + await apiFetch(`/api/ext/pack-route/${PACK}/config`, { + method: "POST", + body: JSON.stringify({ mode: "external", externalUrl: "http://localhost:9177", bank: "keep-me" }), + }); + // A second write touching only uiUrl must preserve the stored bank/externalUrl. + const res = await apiFetch(`/api/ext/pack-route/${PACK}/config`, { + method: "POST", + body: JSON.stringify({ uiUrl: "http://localhost:19177/banks/keep-me" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.config.bank).toBe("keep-me"); + expect(body.config.externalUrl).toBe("http://localhost:9177"); + expect(body.config.uiUrl).toBe("http://localhost:19177/banks/keep-me"); + }); + + test("POST to a NON-config route name is rejected 403 (not a general write seam)", async () => { + test.skip(!ready, "Hindsight built-in config route not served in this environment"); + + const res = await apiFetch(`/api/ext/pack-route/${PACK}/status`, { + method: "POST", + body: JSON.stringify({ anything: true }), + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(String(body.error)).toMatch(/config/i); + + // And the read seam for that same route name still works as a GET. + const getRes = await apiFetch(`/api/ext/pack-route/${PACK}/status`); + expect(getRes.status).toBe(200); + }); + + test("new memory-quality config fields round-trip (defaults + tagsMatch/retainEveryNTurns)", async () => { + test.skip(!ready, "Hindsight built-in config route not served in this environment"); + + // GET reflects the new cost-conscious defaults before any write. + const def = await (await apiFetch(`/api/ext/pack-route/${PACK}/config`)).json(); + expect(def.config.recallScope).toBe("project"); + expect(def.config.tagsMatch).toBe("any"); + expect(def.config.retainEveryNTurns).toBe(5); + + const writeRes = await apiFetch(`/api/ext/pack-route/${PACK}/config`, { + method: "POST", + body: JSON.stringify({ + mode: "external", + externalUrl: "http://localhost:9177", + tagsMatch: "any_strict", + retainEveryNTurns: 3, + recallScope: "all", + }), + }); + expect(writeRes.status).toBe(200); + const w = await writeRes.json(); + expect(w.config.tagsMatch).toBe("any_strict"); + expect(w.config.retainEveryNTurns).toBe(3); + expect(w.config.recallScope).toBe("all"); + }); + + test("a per-project override resolves over the global config with correct precedence (no Docker)", async () => { + test.skip(!ready, "Hindsight built-in config route not served in this environment"); + + const projectId = (await defaultProjectId())!; + // Global: scope all, bank global-bank. + await apiFetch(`/api/ext/pack-route/${PACK}/config`, { + method: "POST", + body: JSON.stringify({ mode: "external", externalUrl: "http://localhost:9177", recallScope: "all", bank: "global-bank" }), + }); + // Per-project overlay: scope project, bank proj-bank (sessionless seam + ?projectId). + const setRes = await apiFetch(`/api/ext/pack-route/${PACK}/config?projectId=${encodeURIComponent(projectId)}`, { + method: "POST", + body: JSON.stringify({ projectOverride: { recallScope: "project", bank: "proj-bank" } }), + }); + expect(setRes.status).toBe(200); + const setBody = await setRes.json(); + expect(setBody.ok).toBe(true); + expect(setBody.config.recallScope).toBe("project"); + expect(setBody.config.bank).toBe("proj-bank"); + expect(setBody.projectOverride).toEqual({ recallScope: "project", bank: "proj-bank" }); + expect(setBody.globalConfig.recallScope).toBe("all"); + expect(setBody.globalConfig.bank).toBe("global-bank"); + + // GET with the project reflects the overlay; GET without it shows the global. + const withProj = await (await apiFetch(`/api/ext/pack-route/${PACK}/config?projectId=${encodeURIComponent(projectId)}`)).json(); + expect(withProj.config.bank).toBe("proj-bank"); + expect(withProj.config.recallScope).toBe("project"); + const noProj = await (await apiFetch(`/api/ext/pack-route/${PACK}/config`)).json(); + expect(noProj.config.bank).toBe("global-bank"); + expect(noProj.config.recallScope).toBe("all"); + }); + + test("an invalid override returns the route's CONFIG_INVALID structured error", async () => { + test.skip(!ready, "Hindsight built-in config route not served in this environment"); + + const res = await apiFetch(`/api/ext/pack-route/${PACK}/config`, { + method: "POST", + body: JSON.stringify({ mode: "not-a-mode" }), + }); + // The route handles the validation failure (200) and returns a structured error + // body rather than throwing — the seam passes the body through unchanged. + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(false); + expect(body.error).toBe("CONFIG_INVALID"); + expect(Array.isArray(body.errors)).toBe(true); + }); +}); diff --git a/tests/e2e/hindsight-external.spec.ts b/tests/e2e/hindsight-external.spec.ts index 830b0bd1d..c1fb2e0fe 100644 --- a/tests/e2e/hindsight-external.spec.ts +++ b/tests/e2e/hindsight-external.spec.ts @@ -17,7 +17,7 @@ * * Assertion surfaces (all token-free, no pack tool/surface needed): * - prompt-sections → the "Dynamic Context" section carries recall blocks. - * - provider-hooks/before-prompt → the per-turn system-prompt tail. + * - provider-hooks/before-prompt → the per-turn dynamic-context message content. * - context-trace → per-provider timing rows + non-fatal diagnostics. * - the stub's own recorders (calls / retained) → what the provider actually * sent to Hindsight (bank id + auto-tag taxonomy). @@ -40,6 +40,7 @@ import { createSession, createGoal, deleteSession, + defaultProjectId, connectWs, agentEndPredicate, messageEndPredicate, @@ -61,10 +62,6 @@ const STUB_PATH = path.resolve(__dirname, "hindsight-stub.mjs"); // The pack-store key the loader persists provider config under. Must mirror // providerConfigStoreKey("memory") in src/server/agent/pack-contributions.ts. const CONFIG_STORE_KEY = "provider-config:memory"; -// Delimiters fencing the dynamic-context region in the system-prompt tail (G1.2). -const DYNAMIC_CONTEXT_START = ""; -const DYNAMIC_CONTEXT_END = ""; - // Gate the suite on the implementation being present so the e2e phase stays green // until the pack + stub are merged from the sibling coder branches. const DEPS_READY = @@ -82,6 +79,7 @@ interface HindsightStub { url: string; calls: RecordedCall[]; setHealthy(ok: boolean): void; + setRecallError(err: { status: number; detail: string } | null): void; seedMemories(bank: string, mem: { text: string; id?: string; score?: number; tags?: string[] }[]): void; retained(bank?: string): RetainedItem[]; close(): Promise; @@ -191,7 +189,7 @@ async function readContextTrace(sessionId: string): Promise { return []; } -interface BeforePromptResult { status: number; tail: string; blocks: Array> } +interface BeforePromptResult { status: number; content: string; blocks: Array> } async function callBeforePrompt(sessionId: string, prompt: string): Promise { const resp = await apiFetch(`/api/sessions/${sessionId}/provider-hooks/before-prompt`, { method: "POST", @@ -200,7 +198,7 @@ async function callBeforePrompt(sessionId: string, prompt: string): Promise { // no hook fires during teardown deletes. await setProviderDisabled([PROVIDER_ID]).catch(() => {}); seedConfig(bobbitDir, null); + stub?.setRecallError(null); for (const id of sessions.splice(0)) await deleteSession(id).catch(() => {}); for (const cwd of cwds.splice(0)) fs.rmSync(cwd, { recursive: true, force: true }); }); @@ -288,13 +287,13 @@ describe("hindsight pack — external mode (stub)", () => { const { id } = await newSession("setup"); - // beforePrompt recall → fenced dynamic-context tail carrying the memory and - // refreshes the prompt-sections Dynamic Context snapshot. + // beforePrompt recall → fenced context block message content carrying the + // memory and refreshes the prompt-sections Dynamic Context snapshot. const before = await callBeforePrompt(id, "how do we roll out risky changes safely?"); expect(before.status).toBe(200); - expect(before.tail).toContain(DYNAMIC_CONTEXT_START); - expect(before.tail).toContain(DYNAMIC_CONTEXT_END); - expect(before.tail).toContain("feature flag"); + expect(before.content).toContain(" { expect(recallCalls.every((c) => c.bank === "bobbit")).toBeTruthy(); }); + test("default config recall scope is project+global: untagged + own-project recalled, other-project excluded", async () => { + // No explicit recallScope ⇒ the new default (`project`, tags_match `any`). This + // proves the acceptance criterion: global/untagged memories ARE still injected + // under the default scope, while another project's tagged memories are NOT. + seedConfig(bobbitDir, defaultConfig(stub.url, { recallScope: undefined })); + await setProviderDisabled([]); + const projectId = (await defaultProjectId())!; + stub.seedMemories("bobbit", [ + { text: "GLOBAL untagged knowledge.", id: "g1" }, // untagged/global ⇒ included by `any` + { text: "MINE project knowledge.", id: "p1", tags: [`project:${projectId}`] }, // included + { text: "THEIRS other-project secret.", id: "o1", tags: ["project:some-other-project"] }, // excluded + ]); + + const { id } = await newSession("default-scope"); + const before = await callBeforePrompt(id, "what do we know?"); + expect(before.status).toBe(200); + expect(before.content).toContain("GLOBAL untagged knowledge."); + expect(before.content).toContain("MINE project knowledge."); + expect(before.content).not.toContain("other-project secret"); + }); + test("a turn remains unaffected while the Hindsight provider is configured", async () => { const goal = await createGoal({ title: "Hindsight configured turn" }); seedConfig(bobbitDir, defaultConfig(stub.url)); @@ -329,7 +349,7 @@ describe("hindsight pack — external mode (stub)", () => { const { id } = await newSession("unhealthy"); const before = await callBeforePrompt(id, "recall should fail non-fatally"); expect(before.status).toBe(200); - expect(before.tail).toBe(""); + expect(before.content).toBe(""); expect(before.blocks).toEqual([]); // A non-fatal diagnostic is recorded against the memory provider. @@ -348,12 +368,38 @@ describe("hindsight pack — external mode (stub)", () => { stub.setHealthy(false); const down = await callBeforePrompt(id, "memory while down"); - expect(down.tail).toBe(""); + expect(down.content).toBe(""); stub.setHealthy(true); stub.seedMemories("bobbit", [{ text: "Recovered recall works.", id: "r1" }]); const up = await callBeforePrompt(id, "memory after recovery"); - expect(up.tail).toContain("Recovered recall works."); + expect(up.content).toContain("Recovered recall works."); + }); + + test("a 400 'Query too long' recall is SOFT-skipped (empty, non-fatal, no sticky diagnostic)", async () => { + // Even with the token-safe clamp, the data plane's 500-token "Query too long" + // 400 must be swallowed: recall is empty for the turn and NO provider error is + // recorded, so the marketplace/panel banner can never reappear from this cause. + seedConfig(bobbitDir, defaultConfig(stub.url)); + await setProviderDisabled([]); + stub.setRecallError({ status: 400, detail: "Query too long: 620 tokens exceeds maximum of 500 tokens" }); + + const { id } = await newSession("query-too-long"); + const before = await callBeforePrompt(id, "an extremely long, dense query the data plane would reject"); + expect(before.status).toBe(200); + expect(before.content).toBe(""); + expect(before.blocks).toEqual([]); + + // Recall WAS attempted against the stub (the 400 fired)... + const recallCalls = stub.calls.filter((c) => /\/memories\/recall$/.test(c.path)); + expect(recallCalls.length).toBeGreaterThan(0); + // ...but it was swallowed: a memory-provider row exists with NO error diagnostic. + const trace = await readContextTrace(id); + const memoryRows = trace.flatMap((e) => e.providers.filter((p) => p.id === PROVIDER_ID)); + expect(memoryRows.length).toBeGreaterThan(0); + expect(memoryRows.every((p) => !p.error)).toBeTruthy(); + + stub.setRecallError(null); }); test("beforeCompact retains the about-to-be-lost span (not an empty no-op)", async () => { @@ -402,7 +448,7 @@ describe("hindsight pack — external mode (stub)", () => { const section = await dynamicContextSection(id); expect(section, "no Dynamic Context section when the provider is disabled").toBeUndefined(); const before = await callBeforePrompt(id, "anything"); - expect(before.tail).toBe(""); + expect(before.content).toBe(""); expect(before.blocks).toEqual([]); // No recall was issued for the disabled provider. expect(stub.calls.length).toBe(callsBefore); diff --git a/tests/e2e/hindsight-stub.mjs b/tests/e2e/hindsight-stub.mjs index 6177ac2ec..7b1d25839 100644 --- a/tests/e2e/hindsight-stub.mjs +++ b/tests/e2e/hindsight-stub.mjs @@ -6,18 +6,6 @@ * (tests/hindsight-client.test.ts), the provider unit test, and the API E2E * (tests/e2e/hindsight-external.spec.ts) all drive this same stub so the wire * contract is exercised end-to-end without a real server. - * - * Canned JSON matches the upstream `openapi.json` response shapes (Hindsight - * 0.8.x). See docs/design/hindsight-pack-external.md §4. - * - * startHindsightStub({ port = 0 }) → { - * url, // base url, e.g. http://127.0.0.1:54321 - * calls, // RecordedCall[] (method, path, bank, namespace, body, headers) - * setHealthy(ok), // false ⇒ /health 503 and recall/retain 503 - * seedMemories(bank, mem[]), // seed recall results (filtered by tags + tags_match) - * retained(bank?), // recorded retained items { content, tags, async } - * close(), // shut the server down - * } */ import http from "node:http"; @@ -28,7 +16,7 @@ import http from "node:http"; * @property {string|undefined} bank * @property {string|undefined} namespace * @property {any} body - * @property {Record} headers + * @property {Record} headers */ function readBody(req) { @@ -53,28 +41,80 @@ function send(res, status, body) { res.end(payload); } -/** Does a seeded memory's tags satisfy the request's tags + tags_match? */ +function operationId(prefix = "op") { + return `${prefix}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** Does a seeded memory's tags satisfy the request's tags + tags_match? + * Mirrors the real Hindsight recall semantics (openapi.json): `any`/`all` are the + * NON-strict variants that INCLUDE untagged/global memories; `any_strict`/ + * `all_strict` EXCLUDE them. Within tagged memories, `all`/`all_strict` require + * every requested tag; `any`/`any_strict` require at least one. */ function tagsMatch(memTags, reqTags, mode) { if (!reqTags || reqTags.length === 0) return true; const have = new Set(memTags ?? []); - // "all"/"all_strict" ⇒ every requested tag present; otherwise (any/any_strict) ⇒ at least one. + // Untagged/global memory: included by the non-strict modes, excluded by `_strict`. + if (have.size === 0) return mode !== "any_strict" && mode !== "all_strict"; if (mode === "all" || mode === "all_strict") { return reqTags.every((t) => have.has(t)); } return reqTags.some((t) => have.has(t)); } +function clone(x) { + return x === undefined ? undefined : structuredClone(x); +} + export function startHindsightStub({ port = 0 } = {}) { /** @type {RecordedCall[]} */ const calls = []; let healthy = true; - /** bank → seeded memory records */ + /** When set, recall responds with this HTTP error: { status, detail }. Models the + * data plane's 500-token "Query too long" 400 so the soft-skip can be exercised. */ + let recallError = null; + let llmHealthy = true; + /** bank → seeded/retained memory records */ const seeded = new Map(); /** bank → retained item records */ const retainedByBank = new Map(); + /** bank → last-applied bank-config `updates` (mission PATCH) */ + const bankConfigByBank = new Map(); + /** bank → mental model id → model */ + const mentalModelsByBank = new Map(); + /** bank → directive id → directive */ + const directivesByBank = new Map(); + /** bank → operations */ + const operationsByBank = new Map(); /** known bank ids (ensured / seeded / retained) */ const banks = new Set(); + function bankMap(map, bank) { + const existing = map.get(bank); + if (existing) return existing; + const created = new Map(); + map.set(bank, created); + return created; + } + + function opList(bank) { + let list = operationsByBank.get(bank); + if (!list) { + list = []; + operationsByBank.set(bank, list); + } + return list; + } + + function addOperation(bank, type, status = "queued") { + const op = { id: operationId(type), type, status, created_at: new Date().toISOString() }; + opList(bank).push(op); + return op; + } + + function memoryList(bank) { + return seeded.get(bank) ?? []; + } + const server = http.createServer(async (req, res) => { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://127.0.0.1"); @@ -108,19 +148,141 @@ export function startHindsightStub({ port = 0 } = {}) { return send(res, 200, { bank_id: bank, name: bank }); } + // PATCH /v1/{ns}/banks/{bank}/config — update_bank_config (mission steering) + if (method === "PATCH" && bank && segs[4] === "config" && segs.length === 5) { + banks.add(bank); + const updates = (body && typeof body === "object" && body.updates) || {}; + bankConfigByBank.set(bank, { ...(bankConfigByBank.get(bank) ?? {}), ...updates }); + return send(res, 200, { bank_id: bank, config: bankConfigByBank.get(bank) }); + } + + // POST /v1/{ns}/banks/{bank}/health/llm + if (method === "POST" && bank && segs[4] === "health" && segs[5] === "llm") { + if (!healthy) return send(res, 503, { detail: "unhealthy" }); + return send(res, 200, { + ok: llmHealthy, + retain: { ok: llmHealthy }, + consolidation: { ok: llmHealthy }, + reflect: { ok: llmHealthy }, + }); + } + + // Operations + if (bank && segs[4] === "operations") { + if (method === "GET" && segs.length === 5) return send(res, 200, { operations: opList(bank) }); + const opId = segs[5] ? decodeURIComponent(segs[5]) : undefined; + if (method === "POST" && opId && segs[6] === "retry") { + const op = opList(bank).find((x) => x.id === opId) ?? { id: opId }; + op.status = "queued"; + return send(res, 200, op); + } + if (method === "DELETE" && opId && segs.length === 6) { + operationsByBank.set(bank, opList(bank).filter((x) => x.id !== opId)); + return send(res, 200, { ok: true }); + } + } + + // Directives + if (bank && segs[4] === "directives") { + const directives = bankMap(directivesByBank, bank); + if (method === "GET" && segs.length === 5) return send(res, 200, { directives: [...directives.values()] }); + if (method === "POST" && segs.length === 5) { + const id = body?.id ?? `dir-${Math.random().toString(36).slice(2, 8)}`; + const directive = { id, ...body }; + directives.set(id, directive); + return send(res, 200, directive); + } + const id = segs[5] ? decodeURIComponent(segs[5]) : undefined; + if (method === "PATCH" && id && directives.has(id)) { + const directive = { ...directives.get(id), ...body }; + directives.set(id, directive); + return send(res, 200, directive); + } + if (method === "DELETE" && id) { + directives.delete(id); + return send(res, 200, { ok: true }); + } + if (id) return send(res, 404, { detail: "directive not found" }); + } + + // Mental models + if (bank && segs[4] === "mental-models") { + const models = bankMap(mentalModelsByBank, bank); + if (method === "GET" && segs.length === 5) return send(res, 200, { mental_models: [...models.values()] }); + if (method === "POST" && segs.length === 5) { + const id = body?.id ?? `model-${Math.random().toString(36).slice(2, 8)}`; + if (models.has(id)) return send(res, 409, { detail: "mental model already exists" }); + const op = addOperation(bank, "mental-model-create"); + const model = { + id, + name: body?.name, + source_query: body?.source_query, + tags: body?.tags ?? [], + max_tokens: body?.max_tokens, + trigger: body?.trigger, + content: body?.content ?? "", + last_refreshed_at: null, + is_stale: false, + operation_id: op.id, + }; + models.set(id, model); + return send(res, 200, { mental_model_id: id, operation_id: op.id }); + } + const id = segs[5] ? decodeURIComponent(segs[5]) : undefined; + if (method === "GET" && id && segs.length === 6) { + const model = models.get(id); + return model ? send(res, 200, model) : send(res, 404, { detail: "mental model not found" }); + } + if (method === "PATCH" && id && segs.length === 6) { + const model = models.get(id); + if (!model) return send(res, 404, { detail: "mental model not found" }); + const next = { ...model, ...body }; + models.set(id, next); + return send(res, 200, next); + } + if (method === "DELETE" && id && segs.length === 6) { + models.delete(id); + return send(res, 200, { ok: true }); + } + if (method === "POST" && id && segs[6] === "refresh") { + const model = models.get(id); + if (!model) return send(res, 404, { detail: "mental model not found" }); + const op = addOperation(bank, "mental-model-refresh"); + model.last_refreshed_at = new Date().toISOString(); + model.is_stale = false; + model.operation_id = op.id; + return send(res, 200, { operation_id: op.id }); + } + if (method === "POST" && id && segs[6] === "clear") { + const model = models.get(id); + if (!model) return send(res, 404, { detail: "mental model not found" }); + const op = addOperation(bank, "mental-model-clear"); + model.content = ""; + model.operation_id = op.id; + return send(res, 200, { operation_id: op.id }); + } + if (method === "GET" && id && segs[6] === "history") { + return send(res, 200, { history: [{ id, event: "created" }] }); + } + } + // POST /v1/{ns}/banks/{bank}/memories/recall — recall_memories if (method === "POST" && bank && segs[4] === "memories" && segs[5] === "recall") { if (!healthy) return send(res, 503, { detail: "unhealthy" }); + if (recallError) return send(res, recallError.status, { detail: recallError.detail }); const reqTags = body?.tags; const mode = body?.tags_match; - const mem = seeded.get(bank) ?? []; - const results = mem + const results = memoryList(bank) + .filter((m) => m.state !== "invalidated") .filter((m) => tagsMatch(m.tags, reqTags, mode)) .map((m) => ({ id: m.id ?? `mem-${Math.random().toString(36).slice(2, 8)}`, - text: m.text, + text: m.text ?? m.content, ...(m.score !== undefined ? { score: m.score } : {}), ...(m.tags ? { tags: m.tags } : {}), + ...(m.document_id ? { document_id: m.document_id } : {}), + ...(m.entities ? { entities: m.entities } : {}), + ...(m.metadata ? { metadata: m.metadata } : {}), })); return send(res, 200, { results }); } @@ -132,10 +294,23 @@ export function startHindsightStub({ port = 0 } = {}) { const items = Array.isArray(body?.items) ? body.items : []; const isAsync = body?.async === true; const list = retainedByBank.get(bank) ?? []; + const mem = seeded.get(bank) ?? []; for (const it of items) { - list.push({ content: it.content, tags: it.tags ?? [], async: isAsync }); + const record = { ...clone(it), async: isAsync, id: it.id ?? it.document_id ?? `ret-${Math.random().toString(36).slice(2, 8)}`, text: it.content }; + if (it.document_id && it.update_mode === "replace") { + const idx = list.findIndex((x) => x.document_id === it.document_id); + if (idx >= 0) list[idx] = record; + else list.push(record); + const memIdx = mem.findIndex((x) => x.document_id === it.document_id); + if (memIdx >= 0) mem[memIdx] = record; + else mem.push(record); + } else { + list.push(record); + mem.push(record); + } } retainedByBank.set(bank, list); + seeded.set(bank, mem); return send(res, 200, { success: true, bank_id: bank, @@ -144,10 +319,29 @@ export function startHindsightStub({ port = 0 } = {}) { }); } + // Memory curation/history/observations + if (bank && segs[4] === "memories" && segs[5] && segs[5] !== "recall") { + const id = decodeURIComponent(segs[5]); + const mem = memoryList(bank); + const idx = mem.findIndex((m) => (m.id ?? m.document_id) === id); + if (method === "PATCH" && segs.length === 6) { + if (idx < 0) return send(res, 404, { detail: "memory not found" }); + mem[idx] = { ...mem[idx], ...body }; + seeded.set(bank, mem); + return send(res, 200, mem[idx]); + } + if (method === "GET" && segs[6] === "history") return send(res, 200, { history: idx >= 0 ? [{ id, state: mem[idx].state ?? "active" }] : [] }); + if (method === "DELETE" && segs[6] === "observations") return send(res, 200, { ok: true }); + } + // POST /v1/{ns}/banks/{bank}/reflect — reflect if (method === "POST" && bank && segs[4] === "reflect") { if (!healthy) return send(res, 503, { detail: "unhealthy" }); - return send(res, 200, { text: `Reflection on: ${body?.query ?? ""}` }); + const response = { text: `Reflection on: ${body?.query ?? ""}` }; + if (body?.response_schema) { + response.structured_output = { ok: true, schema: body.response_schema }; + } + return send(res, 200, response); } return send(res, 404, { detail: `no stub route for ${method} ${pathname}` }); @@ -163,13 +357,45 @@ export function startHindsightStub({ port = 0 } = {}) { setHealthy(ok) { healthy = ok; }, + setLlmHealthy(ok) { + llmHealthy = ok; + }, + setRecallError(err) { + recallError = err ?? null; + }, seedMemories(bank, mem) { banks.add(bank); - seeded.set(bank, [...(seeded.get(bank) ?? []), ...mem]); + seeded.set(bank, [...(seeded.get(bank) ?? []), ...clone(mem)]); + }, + seedMentalModel(bank, model) { + banks.add(bank); + bankMap(mentalModelsByBank, bank).set(model.id, clone(model)); + }, + seedDirective(bank, directive) { + banks.add(bank); + bankMap(directivesByBank, bank).set(directive.id, clone(directive)); }, retained(bank) { - if (bank) return [...(retainedByBank.get(bank) ?? [])]; - return [...retainedByBank.values()].flat(); + if (bank) return clone(retainedByBank.get(bank) ?? []); + return clone([...retainedByBank.values()].flat()); + }, + recalledTypes(bank) { + return calls + .filter((c) => c.bank === bank && c.path.endsWith("/memories/recall")) + .map((c) => c.body?.types) + .reverse(); + }, + mentalModels(bank) { + return clone([...(mentalModelsByBank.get(bank)?.values() ?? [])]); + }, + directives(bank) { + return clone([...(directivesByBank.get(bank)?.values() ?? [])]); + }, + operations(bank) { + return clone(opList(bank)); + }, + bankConfig(bank) { + return bankConfigByBank.get(bank) ?? null; }, close() { return new Promise((res) => server.close(() => res())); diff --git a/tests/e2e/marketplace-runtime-activation.spec.ts b/tests/e2e/marketplace-runtime-activation.spec.ts new file mode 100644 index 000000000..bbc9ed551 --- /dev/null +++ b/tests/e2e/marketplace-runtime-activation.spec.ts @@ -0,0 +1,743 @@ +/** + * API E2E — P3 managed-runtime activation/consent wiring. + * + * Exercises the server-side activation side effects with the Docker layer FULLY + * MOCKED (a fake PackRuntimeSupervisor injected via registerPackRuntimeSupervisorFactory). + * Packs are written to disk at server scope (mirroring marketplace-provider-activation). + * + * Pins the P3 hard invariants: + * - Enabling a `startPolicy: on-enable` runtime in a MANAGED mode IS the explicit + * start action (calls supervisor.start exactly once); disabling calls stop. + * - EXTERNAL mode never starts a container on enable (non-Docker setup path). + * - Reading (GET pack-activation / GET pack-runtimes) never starts a runtime. + * - Uninstall tears down WITHOUT volumes (data survives); explicit purge runs + * down WITH volumes + state removal. + */ +import { test, expect } from "./in-process-harness.js"; +import { apiFetch } from "./e2e-setup.js"; +import { encodePackRuntimeId } from "../../dist/server/runtimes/index.js"; +import fs from "node:fs"; +import path from "node:path"; + +// ── Fake supervisor (no Docker). Records every control call. ───────────────── + +interface SupCall { + op: "start" | "stop" | "down" | "restart"; + packId: string; + runtimeId: string; + opts?: Record; +} +const calls: SupCall[] = []; + +function statusFor(packId: string, runtimeId: string, status: string, mode?: string) { + return { id: `${packId}:${runtimeId}`, packId, runtimeId, status, mode, composeProject: `bobbit-pack-${packId}-test` }; +} + +const fakeSupervisor = { + async list() { return []; }, + async status(packId: string, runtimeId: string) { return statusFor(packId, runtimeId, "stopped"); }, + async start(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "start", packId, runtimeId, opts }); + return statusFor(packId, runtimeId, "running", opts?.mode as string | undefined); + }, + async stop(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "stop", packId, runtimeId, opts }); + return statusFor(packId, runtimeId, "stopped"); + }, + async restart(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "restart", packId, runtimeId, opts }); + return statusFor(packId, runtimeId, "running"); + }, + async down(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "down", packId, runtimeId, opts }); + return statusFor(packId, runtimeId, "stopped"); + }, + async logs() { return ""; }, + async capabilitySummary(packId: string, runtimeId: string, opts?: Record) { + // Echo the effective deployment config's dataDir into volumePath so a test can + // prove the route forwards the SAME effective config used by activation (e.g. a + // custom bind path), not just schema defaults. + const config = (opts?.config ?? {}) as Record; + const volumePath = typeof config.dataDir === "string" && config.dataDir.length > 0 ? config.dataDir : "~/.hindsight"; + return { ...statusFor(packId, runtimeId, "stopped"), mode: (opts?.mode as string | undefined) ?? "managed-postgres", startPolicy: "on-enable", services: ["api", "web", "db"], images: ["api", "web", "db"], ports: [], volumePath, trust: "x" }; + }, +}; + +// ── Pack-on-disk helpers (server scope). ───────────────────────────────────── + +function writeMeta(packDir: string, packName: string): void { + fs.writeFileSync(path.join(packDir, ".pack-meta.yaml"), [ + "sourceUrl: e2e", + "sourceRef: local", + "commit: test", + `packName: ${packName}`, + "version: 1.0.0", + "installedAt: '2026-01-01T00:00:00.000Z'", + "updatedAt: '2026-01-01T00:00:00.000Z'", + "scope: server", + ].join("\n") + "\n", "utf-8"); +} + +/** A schema-v2 pack declaring a managed runtime (startPolicy: on-enable) and a + * memory provider carrying the deployment `mode` (drives the start plan). */ +function writeRuntimePack(root: string, packName: string, mode: "external" | "managed" | "managed-external-postgres", opts: { extraProviderConfig?: string[]; dataDir?: string } = {}): string { + const packDir = path.join(root, ".bobbit", "config", "market-packs", packName); + fs.mkdirSync(path.join(packDir, "providers"), { recursive: true }); + fs.mkdirSync(path.join(packDir, "runtimes"), { recursive: true }); + fs.mkdirSync(path.join(packDir, "runtime"), { recursive: true }); + fs.mkdirSync(path.join(packDir, "lib"), { recursive: true }); + fs.writeFileSync(path.join(packDir, "pack.yaml"), [ + "schema: 2", + `name: ${packName}`, + "description: Runtime activation e2e", + "version: 1.0.0", + "contents:", + " roles: []", + " tools: []", + " skills: []", + " entrypoints: []", + " providers: [memory]", + " runtimes: [hindsight]", + ].join("\n") + "\n", "utf-8"); + writeMeta(packDir, packName); + // Provider carries the deployment mode as its config default. + fs.writeFileSync(path.join(packDir, "providers", "memory.yaml"), [ + "id: memory", + "kind: memory", + "module: ../lib/provider.mjs", + "hooks: [beforePrompt]", + "config:", + ` mode: { type: enum, values: [external, managed, managed-external-postgres], default: ${mode} }`, + " externalUrl: { type: string, optional: true }", + ` dataDir: { type: string, default: ${opts.dataDir ?? "~/.hindsight"} }`, + ...(opts.extraProviderConfig ?? []), + ].join("\n") + "\n", "utf-8"); + // Minimal but realistic runtime descriptor (raw manifest is carried verbatim; + // the activation hook only reads startPolicy + forwards to the supervisor). + fs.writeFileSync(path.join(packDir, "runtimes", "hindsight.yaml"), [ + "id: hindsight", + "title: Hindsight", + "startPolicy: on-enable", + "composeFile: ../runtime/compose.yaml", + "modes:", + " managed-postgres: { services: [api, web, db] }", + " external-postgres: { services: [api, web, db], omitServices: [db] }", + ].join("\n") + "\n", "utf-8"); + fs.writeFileSync(path.join(packDir, "runtime", "compose.yaml"), "services:\n api: { image: hindsight/api }\n", "utf-8"); + fs.writeFileSync(path.join(packDir, "lib", "provider.mjs"), "export default {};\n", "utf-8"); + return packDir; +} + +/** A schema-v2 pack declaring a managed runtime (startPolicy: on-enable) but NO + * provider — i.e. no deployment-config surface. resolveRuntimeStartPlan({}) + * defaults to external/no-start, so without the no-surface fallback the + * `on-enable` runtime would never start. */ +function writeProviderlessRuntimePack(root: string, packName: string): string { + const packDir = path.join(root, ".bobbit", "config", "market-packs", packName); + fs.mkdirSync(path.join(packDir, "runtimes"), { recursive: true }); + fs.mkdirSync(path.join(packDir, "runtime"), { recursive: true }); + fs.writeFileSync(path.join(packDir, "pack.yaml"), [ + "schema: 2", + `name: ${packName}`, + "description: Provider-less runtime activation e2e", + "version: 1.0.0", + "contents:", + " roles: []", + " tools: []", + " skills: []", + " entrypoints: []", + " providers: []", + " runtimes: [hindsight]", + ].join("\n") + "\n", "utf-8"); + writeMeta(packDir, packName); + fs.writeFileSync(path.join(packDir, "runtimes", "hindsight.yaml"), [ + "id: hindsight", + "title: Hindsight", + "startPolicy: on-enable", + "composeFile: ../runtime/compose.yaml", + "modes:", + " managed-postgres: { services: [api, web, db] }", + ].join("\n") + "\n", "utf-8"); + fs.writeFileSync(path.join(packDir, "runtime", "compose.yaml"), "services:\n api: { image: hindsight/api }\n", "utf-8"); + return packDir; +} + +/** A schema-v2 pack declaring a managed runtime (startPolicy: on-enable) AND a + * provider — but a provider that carries NO deployment `mode` (an UNRELATED + * provider). Per finding #2 this must behave EXACTLY like a provider-less runtime + * pack: `hasDeploymentSurface` is false (a provider alone is not a deployment + * surface), so the `on-enable` runtime starts in the manifest DEFAULT mode. */ +function writeNoModeProviderRuntimePack(root: string, packName: string): string { + const packDir = path.join(root, ".bobbit", "config", "market-packs", packName); + fs.mkdirSync(path.join(packDir, "providers"), { recursive: true }); + fs.mkdirSync(path.join(packDir, "runtimes"), { recursive: true }); + fs.mkdirSync(path.join(packDir, "runtime"), { recursive: true }); + fs.mkdirSync(path.join(packDir, "lib"), { recursive: true }); + fs.writeFileSync(path.join(packDir, "pack.yaml"), [ + "schema: 2", + `name: ${packName}`, + "description: No-mode provider runtime e2e", + "version: 1.0.0", + "contents:", + " roles: []", + " tools: []", + " skills: []", + " entrypoints: []", + " providers: [aux]", + " runtimes: [hindsight]", + ].join("\n") + "\n", "utf-8"); + writeMeta(packDir, packName); + // Provider with NO `mode` and NO activeWhenConfig.mode → not a deployment surface. + fs.writeFileSync(path.join(packDir, "providers", "aux.yaml"), [ + "id: aux", + "kind: generic", + "module: ../lib/provider.mjs", + "hooks: [beforePrompt]", + "config:", + " label: { type: string, default: hello }", + ].join("\n") + "\n", "utf-8"); + fs.writeFileSync(path.join(packDir, "runtimes", "hindsight.yaml"), [ + "id: hindsight", + "title: Hindsight", + "startPolicy: on-enable", + "composeFile: ../runtime/compose.yaml", + "modes:", + " managed-postgres: { services: [api, web, db] }", + ].join("\n") + "\n", "utf-8"); + fs.writeFileSync(path.join(packDir, "runtime", "compose.yaml"), "services:\n api: { image: hindsight/api }\n", "utf-8"); + fs.writeFileSync(path.join(packDir, "lib", "provider.mjs"), "export default {};\n", "utf-8"); + return packDir; +} + +async function setDisabledRuntimes(packName: string, runtimes: string[]) { + return apiFetch("/api/marketplace/pack-activation", { + method: "PUT", + body: JSON.stringify({ scope: "server", packName, disabled: { runtimes } }), + }); +} + +/** The persisted disabled-runtime refs for a pack (server scope). */ +async function getDisabledRuntimes(packName: string): Promise { + const res = await apiFetch(`/api/marketplace/pack-activation?scope=server&packName=${encodeURIComponent(packName)}`); + const body = await res.json(); + return (body?.disabled?.runtimes ?? []) as string[]; +} + +test.describe("marketplace managed-runtime activation (P3)", () => { + test.beforeAll(async () => { + const mod = await import("../../dist/server/server.js"); + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + }); + test.afterAll(async () => { + const mod = await import("../../dist/server/server.js"); + mod.registerPackRuntimeSupervisorFactory(null); + }); + test.beforeEach(() => { calls.length = 0; }); + + test("managed mode: enabling an on-enable runtime starts it once; disabling stops it", async ({ gateway }) => { + const packName = `rt-managed-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + // 1. Disable the runtime first (enabled → disabled) → exactly one stop. + const disable = await setDisabledRuntimes(packName, ["hindsight"]); + expect(disable.status).toBe(200); + expect(calls.filter((c) => c.op === "stop").length).toBe(1); + expect(calls.some((c) => c.op === "start")).toBe(false); + + calls.length = 0; + // 2. Re-enable (disabled → enabled) → explicit managed start, exactly once. + const enable = await setDisabledRuntimes(packName, []); + expect(enable.status).toBe(200); + const startCalls = calls.filter((c) => c.op === "start"); + expect(startCalls.length).toBe(1); + expect(startCalls[0].runtimeId).toBe("hindsight"); + // managed → runtime mode managed-postgres is forwarded to the supervisor. + expect(startCalls[0].opts?.mode).toBe("managed-postgres"); + // The activation response surfaces the runtime status. + const body = await enable.json(); + expect(Array.isArray(body.runtimes)).toBe(true); + expect(body.runtimes[0].status).toBe("running"); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("managed mode forwards the provider llmApiKey onto the runtime HINDSIGHT_API_LLM_API_KEY secret (finding #1)", async ({ gateway }) => { + const packName = `rt-llmkey-${Date.now()}`; + // The managed Hindsight runtime requires HINDSIGHT_API_LLM_API_KEY (a user- + // configured secret env ref). The provider exposes it via the `llmApiKey` + // deployment-config field; the activation start plan must remap it onto the + // runtime env key so the supervisor's config overlay can satisfy it. + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed", { + extraProviderConfig: [" llmApiKey: { type: string, default: test-llm-key }"], + }); + try { + await setDisabledRuntimes(packName, ["hindsight"]); + calls.length = 0; + const enable = await setDisabledRuntimes(packName, []); + expect(enable.status).toBe(200); + const startCalls = calls.filter((c) => c.op === "start"); + expect(startCalls.length).toBe(1); + const config = startCalls[0].opts?.config as Record | undefined; + expect(config?.HINDSIGHT_API_LLM_API_KEY).toBe("test-llm-key"); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("provider-less runtime pack: enabling an on-enable runtime starts it in the default mode (finding #2 — no deployment surface)", async ({ gateway }) => { + // A runtime-only pack has NO provider deployment config, so the start plan + // defaults to external/no-start. The activation path must mirror the REST start + // path's no-deployment-surface fallback: an `on-enable` runtime starts in the + // runtime's DEFAULT mode (mode undefined ⇒ supervisor picks the manifest default), + // rather than being suppressed by the external default. + const packName = `rt-providerless-${Date.now()}`; + const packDir = writeProviderlessRuntimePack(gateway.bobbitDir, packName); + try { + // Disable then re-enable to drive the explicit disabled → enabled start path. + await setDisabledRuntimes(packName, ["hindsight"]); + calls.length = 0; + const enable = await setDisabledRuntimes(packName, []); + expect(enable.status).toBe(200); + const startCalls = calls.filter((c) => c.op === "start"); + expect(startCalls.length).toBe(1); + expect(startCalls[0].runtimeId).toBe("hindsight"); + // No deployment surface → no managed/external mode forwarded; the supervisor + // resolves the runtime's default mode itself. + expect(startCalls[0].opts?.mode).toBeUndefined(); + const body = await enable.json(); + expect(Array.isArray(body.runtimes)).toBe(true); + expect(body.runtimes[0].status).toBe("running"); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("unrelated-provider runtime pack: an on-enable runtime starts in the DEFAULT mode — a provider with no deployment mode is NOT a deployment surface (finding #2)", async ({ gateway }) => { + // A pack with a provider that carries no deployment `mode` has no external/managed + // concept, so it must behave like a provider-less runtime pack: the no-deployment- + // surface fallback starts the `on-enable` runtime in the manifest DEFAULT mode + // (mode undefined) rather than being suppressed by the external-default plan. + const packName = `rt-nomode-${Date.now()}`; + const packDir = writeNoModeProviderRuntimePack(gateway.bobbitDir, packName); + try { + await setDisabledRuntimes(packName, ["hindsight"]); + calls.length = 0; + const enable = await setDisabledRuntimes(packName, []); + expect(enable.status).toBe(200); + const startCalls = calls.filter((c) => c.op === "start"); + expect(startCalls.length).toBe(1); + expect(startCalls[0].runtimeId).toBe("hindsight"); + // No deployment surface → no mode forwarded; the supervisor picks the default. + expect(startCalls[0].opts?.mode).toBeUndefined(); + const body = await enable.json(); + expect(body.runtimes[0].status).toBe("running"); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("capabilities for a provider-less runtime pack disclose the manifest DEFAULT Docker mode/services — not external/no-Docker (finding #1)", async ({ gateway }) => { + // With no deployment surface the disclosure must show the manifest default (Docker) + // mode/services so the consent UI matches what activation/start actually brings up, + // rather than collapsing to the external (no-Docker) setup path. + const packName = `rt-cap-providerless-${Date.now()}`; + const packDir = writeProviderlessRuntimePack(gateway.bobbitDir, packName); + try { + const id = encodePackRuntimeId(packName, "hindsight"); + const res = await apiFetch(`/api/pack-runtimes/${id}/capabilities`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.dockerRequired).toBe(true); + expect(body.mode).toBe("managed-postgres"); + expect(body.services).toEqual(["api", "web", "db"]); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("capabilities for an unrelated-provider runtime pack also disclose the default Docker mode (finding #1)", async ({ gateway }) => { + const packName = `rt-cap-nomode-${Date.now()}`; + const packDir = writeNoModeProviderRuntimePack(gateway.bobbitDir, packName); + try { + const id = encodePackRuntimeId(packName, "hindsight"); + const res = await apiFetch(`/api/pack-runtimes/${id}/capabilities`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.dockerRequired).toBe(true); + expect(body.mode).toBe("managed-postgres"); + expect(body.services).toEqual(["api", "web", "db"]); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("disabled runtime: the runtime REST 404s (registry no longer resolves it) (finding #3)", async ({ gateway }) => { + // Disabling a runtime via pack_activation drops it from the pack's contributions, + // so the supervisor's registry lookup misses → 404 on capabilities/start. Use the + // REAL (registry-backed) supervisor here: the fake supervisor never consults the + // registry, so the rejection must come from the registry filtering itself. + const mod = await import("../../dist/server/server.js"); + const packName = `rt-disabled-rest-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + // Disable the runtime (fake supervisor present → harmless stop). + const disable = await setDisabledRuntimes(packName, ["hindsight"]); + expect(disable.status).toBe(200); + // Switch to the real, registry-backed supervisor so the lookup is registry-driven. + mod.registerPackRuntimeSupervisorFactory(null); + const id = encodePackRuntimeId(packName, "hindsight"); + const caps = await apiFetch(`/api/pack-runtimes/${id}/capabilities`); + expect(caps.status).toBe(404); + const start = await apiFetch(`/api/pack-runtimes/${id}/start`, { method: "POST", body: JSON.stringify({ mode: "managed-postgres" }) }); + expect(start.status).toBe(404); + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("external mode: enabling the runtime NEVER starts a container (non-Docker setup path)", async ({ gateway }) => { + const packName = `rt-external-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "external"); + try { + // Disable then re-enable; the external deployment mode must avoid start entirely. + await setDisabledRuntimes(packName, ["hindsight"]); + calls.length = 0; + const enable = await setDisabledRuntimes(packName, []); + expect(enable.status).toBe(200); + expect(calls.some((c) => c.op === "start")).toBe(false); + // No runtime status surfaced because no supervisor action was taken. + const body = await enable.json(); + expect(body.runtimes).toBeUndefined(); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("disable stops the runtime even when the CURRENT mode is external (managed-start leak guard, review finding)", async ({ gateway }) => { + // Scenario: the runtime was previously started in a MANAGED mode, then the saved + // deployment config was changed to `external`. Teardown must NOT be gated on the + // CURRENT saved mode — gating skips the stop and leaks the still-running container. + // stop() is now read-only/minimal/idempotent (it never resolves start-only inputs + // like HINDSIGHT_API_LLM_API_KEY, reuses an already-rendered .env only when one + // exists, and maps a missing Docker install to a docker-unavailable STATUS rather + // than throwing), so calling it for an external/never-started runtime is harmless. + const packName = `rt-external-disable-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "external"); + try { + expect(await getDisabledRuntimes(packName)).not.toContain("hindsight"); + const disable = await setDisabledRuntimes(packName, ["hindsight"]); + expect(disable.status).toBe(200); + // stop IS called now (unconditional teardown) and harmlessly returns stopped. + const stopCalls = calls.filter((c) => c.op === "stop"); + expect(stopCalls.length).toBe(1); + expect(stopCalls[0].runtimeId).toBe("hindsight"); + // The disable still persists. + expect(await getDisabledRuntimes(packName)).toContain("hindsight"); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("uninstall tears the runtime down (without -v) even when the CURRENT mode is external (managed-start leak guard, review finding)", async ({ gateway }) => { + // Mirror of the disable guard for uninstall: a runtime started managed and later + // reconfigured to external must still be `compose down`-ed on uninstall, or the + // container leaks. down() is read-only/minimal/idempotent (never resolves managed + // start-only inputs, maps no-Docker to a status), so it is harmless for an + // external/never-started runtime. Default uninstall preserves data (no `-v`). + const packName = `rt-external-uninstall-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "external"); + try { + const res = await apiFetch("/api/marketplace/installed", { + method: "DELETE", + body: JSON.stringify({ scope: "server", packName }), + }); + expect(res.status).toBe(204); + // down IS called now (unconditional teardown) WITHOUT volumes (bind data survives). + const downCalls = calls.filter((c) => c.op === "down"); + expect(downCalls.length).toBe(1); + expect(downCalls[0].runtimeId).toBe("hindsight"); + expect(downCalls[0].opts?.volumes).toBe(false); + expect(downCalls[0].opts?.removeState).toBe(false); + // The pack is gone from the install ledger. + const listed = await apiFetch("/api/marketplace/installed"); + const installed = (await listed.json()).installed as Array<{ packName: string; scope: string }>; + expect(installed.some((p) => p.packName === packName && p.scope === "server")).toBe(false); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("managed mode: UNINSTALL still tears the runtime down (review finding #2 — managed unchanged)", async ({ gateway }) => { + const packName = `rt-managed-uninstall-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + const res = await apiFetch("/api/marketplace/installed", { + method: "DELETE", + body: JSON.stringify({ scope: "server", packName }), + }); + expect(res.status).toBe(204); + const downCalls = calls.filter((c) => c.op === "down"); + expect(downCalls.length).toBe(1); + expect(downCalls[0].opts?.volumes).toBe(false); + expect(downCalls[0].opts?.removeState).toBe(false); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("capabilities for managed mode discloses the CUSTOM dataDir bind path activation uses (review finding #3)", async ({ gateway }) => { + const packName = `rt-capabilities-datadir-${Date.now()}`; + const customPath = "/srv/custom-hindsight-data"; + // Managed mode with a non-default dataDir. The capabilities route must resolve the + // SAME effective deployment config activation uses and forward it, so the consent + // disclosure shows the custom bind path — not the schema default. + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed", { dataDir: customPath }); + try { + // packId for a server-scope disk pack under market-packs/ is . + const id = encodePackRuntimeId(packName, "hindsight"); + const res = await apiFetch(`/api/pack-runtimes/${id}/capabilities`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.mode).toBe("managed-postgres"); + expect(body.dockerRequired).toBe(true); + expect(body.volumePath).toBe(customPath); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("reads never auto-start: GET pack-activation + GET pack-runtimes issue no start", async ({ gateway }) => { + const packName = `rt-noauto-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + const get = await apiFetch(`/api/marketplace/pack-activation?scope=server&packName=${encodeURIComponent(packName)}`); + expect(get.status).toBe(200); + const body = await get.json(); + expect(body.catalogue.runtimes).toEqual(["hindsight"]); + await apiFetch("/api/pack-runtimes"); + // Pure reads — listing/inspecting must never bring a runtime up. + expect(calls.some((c) => c.op === "start")).toBe(false); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("uninstall tears the runtime down WITHOUT volumes (bind data survives)", async ({ gateway }) => { + const packName = `rt-uninstall-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + const res = await apiFetch("/api/marketplace/installed", { + method: "DELETE", + body: JSON.stringify({ scope: "server", packName }), + }); + expect(res.status).toBe(204); + const downCalls = calls.filter((c) => c.op === "down"); + expect(downCalls.length).toBe(1); + expect(downCalls[0].runtimeId).toBe("hindsight"); + expect(downCalls[0].opts?.volumes).toBe(false); + expect(downCalls[0].opts?.removeState).toBe(false); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("uninstall reports a REAL teardown failure (down throws) and does NOT uninstall", async ({ gateway }) => { + const mod = await import("../../dist/server/server.js"); + const packName = `rt-uninstall-fail-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + mod.registerPackRuntimeSupervisorFactory(() => ({ + ...fakeSupervisor, + async down(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "down", packId, runtimeId, opts }); + throw new Error("compose down exploded"); + }, + })); + try { + const res = await apiFetch("/api/marketplace/installed", { + method: "DELETE", + body: JSON.stringify({ scope: "server", packName }), + }); + // A real Docker teardown failure is reported — never silently swallowed. + expect(res.status).toBe(502); + const body = await res.json(); + expect(String(body.error)).toContain("teardown failed"); + expect(Array.isArray(body.details)).toBe(true); + expect(body.details.join(" ")).toContain("compose down exploded"); + // The pack is STILL installed (the uninstall was aborted, not silently completed). + const listed = await apiFetch("/api/marketplace/installed"); + const installed = (await listed.json()).installed as Array<{ packName: string; scope: string }>; + expect(installed.some((p) => p.packName === packName && p.scope === "server")).toBe(true); + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("uninstall TOLERATES a docker-unavailable runtime (down returns status, no throw) and still uninstalls", async ({ gateway }) => { + const mod = await import("../../dist/server/server.js"); + const packName = `rt-uninstall-nodocker-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + mod.registerPackRuntimeSupervisorFactory(() => ({ + ...fakeSupervisor, + async down(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "down", packId, runtimeId, opts }); + return statusFor(packId, runtimeId, "docker-unavailable"); + }, + })); + try { + const res = await apiFetch("/api/marketplace/installed", { + method: "DELETE", + body: JSON.stringify({ scope: "server", packName }), + }); + // A docker-unavailable STATUS (graceful, no throw) must not block uninstall. + expect(res.status).toBe(204); + expect(calls.filter((c) => c.op === "down").length).toBe(1); + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("enable FAILURE (start throws) returns 502 and does NOT persist enabled (finding #2)", async ({ gateway }) => { + const mod = await import("../../dist/server/server.js"); + const packName = `rt-enable-throw-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + // Start disabled (persisted), then make start() throw on the re-enable. + await setDisabledRuntimes(packName, ["hindsight"]); + expect(await getDisabledRuntimes(packName)).toContain("hindsight"); + mod.registerPackRuntimeSupervisorFactory(() => ({ + ...fakeSupervisor, + async start(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "start", packId, runtimeId, opts }); + throw new Error("compose up exploded"); + }, + })); + const enable = await setDisabledRuntimes(packName, []); + // A thrown start aborts the PUT — never a swallowed 200. + expect(enable.status).toBe(502); + const body = await enable.json(); + expect(String(body.error)).toContain("compose up exploded"); + expect(body.runtimes[0].status).toBe("error"); + // State is unchanged: the runtime is STILL disabled (the enable did not take). + expect(body.disabled.runtimes).toContain("hindsight"); + expect(await getDisabledRuntimes(packName)).toContain("hindsight"); + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("enable FAILURE (start returns unhealthy) returns 502 and does NOT persist enabled (finding #2)", async ({ gateway }) => { + const mod = await import("../../dist/server/server.js"); + const packName = `rt-enable-unhealthy-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + await setDisabledRuntimes(packName, ["hindsight"]); + mod.registerPackRuntimeSupervisorFactory(() => ({ + ...fakeSupervisor, + async start(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "start", packId, runtimeId, opts }); + return statusFor(packId, runtimeId, "unhealthy", opts?.mode as string | undefined); + }, + })); + const enable = await setDisabledRuntimes(packName, []); + expect(enable.status).toBe(502); + const body = await enable.json(); + expect(String(body.error)).toContain("failed to start"); + expect(await getDisabledRuntimes(packName)).toContain("hindsight"); + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("disable FAILURE (stop throws) returns 502 and does NOT persist disabled (finding #2)", async ({ gateway }) => { + const mod = await import("../../dist/server/server.js"); + const packName = `rt-disable-throw-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + // Runtime starts enabled (empty disabled set). Make stop() throw on disable. + expect(await getDisabledRuntimes(packName)).not.toContain("hindsight"); + mod.registerPackRuntimeSupervisorFactory(() => ({ + ...fakeSupervisor, + async stop(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "stop", packId, runtimeId, opts }); + throw new Error("compose stop exploded"); + }, + })); + const disable = await setDisabledRuntimes(packName, ["hindsight"]); + expect(disable.status).toBe(502); + const body = await disable.json(); + expect(String(body.error)).toContain("compose stop exploded"); + // The runtime is STILL enabled — a failed stop must not record it disabled. + expect(body.disabled.runtimes ?? []).not.toContain("hindsight"); + expect(await getDisabledRuntimes(packName)).not.toContain("hindsight"); + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("enable TOLERATES docker-unavailable: persists enabled, reports status, no 502 (finding #2)", async ({ gateway }) => { + const mod = await import("../../dist/server/server.js"); + const packName = `rt-enable-nodocker-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + await setDisabledRuntimes(packName, ["hindsight"]); + mod.registerPackRuntimeSupervisorFactory(() => ({ + ...fakeSupervisor, + async start(packId: string, runtimeId: string, opts?: Record) { + calls.push({ op: "start", packId, runtimeId, opts }); + return statusFor(packId, runtimeId, "docker-unavailable", opts?.mode as string | undefined); + }, + })); + const enable = await setDisabledRuntimes(packName, []); + // docker-unavailable is graceful — the enable persists and is reported (not a 502). + expect(enable.status).toBe(200); + const body = await enable.json(); + expect(body.runtimes[0].status).toBe("docker-unavailable"); + expect(await getDisabledRuntimes(packName)).not.toContain("hindsight"); + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("explicit purge runs down WITH volumes + state removal", async ({ gateway }) => { + const packName = `rt-purge-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + const res = await apiFetch("/api/marketplace/purge-runtime", { + method: "POST", + body: JSON.stringify({ scope: "server", packName, runtimeId: "hindsight" }), + }); + expect(res.status).toBe(200); + const downCalls = calls.filter((c) => c.op === "down"); + expect(downCalls.length).toBe(1); + expect(downCalls[0].opts?.volumes).toBe(true); + expect(downCalls[0].opts?.removeState).toBe(true); + const data = await res.json(); + expect(data.status).toBe("stopped"); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); + + test("purge-runtime for an unknown runtime → 404", async ({ gateway }) => { + const packName = `rt-purge-404-${Date.now()}`; + const packDir = writeRuntimePack(gateway.bobbitDir, packName, "managed"); + try { + const res = await apiFetch("/api/marketplace/purge-runtime", { + method: "POST", + body: JSON.stringify({ scope: "server", packName, runtimeId: "ghost" }), + }); + expect(res.status).toBe(404); + expect(calls.some((c) => c.op === "down")).toBe(false); + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/e2e/pack-default-disabled.spec.ts b/tests/e2e/pack-default-disabled.spec.ts new file mode 100644 index 000000000..348bcac01 --- /dev/null +++ b/tests/e2e/pack-default-disabled.spec.ts @@ -0,0 +1,207 @@ +/** + * API E2E — default-disabled built-in pack resolution (server-side mechanism). + * + * A pack whose manifest declares `defaultDisabled: true` ships DORMANT: on a + * fresh server it lists in the Marketplace but resolves with EVERY contributed + * entity de-activated (tools / provider / entrypoints / runtime all absent) + * UNTIL the user explicitly enables it OR it is "already configured" (a live + * setup must keep working untouched). An explicit user toggle always wins and + * persists. + * + * This drives the END-TO-END server wiring (resolver injection into the + * server-scope activation store + the activation PUT's force-enable marker + the + * `/api/marketplace/installed` payload field) through the real REST endpoints. We + * use a SYNTHETIC server-scope pack (a fresh, uniquely-named pack we fully own) + * rather than the built-in `hindsight` pack so the matrix is deterministic and + * cannot be contaminated by sibling Hindsight specs sharing the worker gateway. + * + * Observable surface: `GET /api/marketplace/pack-activation` returns the + * EFFECTIVE `disabled` refs (the overlay applies through getPackActivation), so a + * dormant pack reports all entities disabled and an enabled pack reports `{}`. + */ +import { test as base, expect } from "./in-process-harness.js"; +import { apiFetch } from "./e2e-setup.js"; +import fs from "node:fs"; +import path from "node:path"; + +const test = base; + +const PACK_NAME = "edt-dormant"; +const PROVIDER_ID = "mem"; +const TOOL_NAME = "edt_dormant_tool"; +// Mirror providerConfigStoreKey(PROVIDER_ID) in pack-contributions.ts. +const CONFIG_STORE_KEY = `provider-config:${PROVIDER_ID}`; + +/** Percent-encode every non-alphanumeric byte — mirrors pack-store.ts::encodeKey. */ +function encodeStoreKey(key: string): string { + const bytes = Buffer.from(key, "utf8"); + let out = ""; + for (const b of bytes) { + const isAlnum = (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a); + out += isAlnum ? String.fromCharCode(b) : `%${b.toString(16).toUpperCase().padStart(2, "0")}`; + } + return out; +} + +/** Lay down a minimal server-scope market pack that ships `defaultDisabled: true`, + * with one tool group + one memory provider (for the configured-check). */ +function installPack(bobbitDir: string): void { + const root = path.join(bobbitDir, ".bobbit", "config", "market-packs", PACK_NAME); + fs.rmSync(root, { recursive: true, force: true }); + fs.mkdirSync(path.join(root, "tools", "g"), { recursive: true }); + fs.mkdirSync(path.join(root, "providers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "pack.yaml"), + [ + "schema: 2", + `name: ${PACK_NAME}`, + "description: synthetic default-disabled pack for E2E", + "version: 1.0.0", + "defaultDisabled: true", + "contents:", + " roles: []", + " tools: [g]", + " skills: []", + " entrypoints: []", + ` providers: [${PROVIDER_ID}]`, + " runtimes: []", + "", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(root, "tools", "g", `${TOOL_NAME}.yaml`), + [`name: ${TOOL_NAME}`, "description: synthetic dormant tool", "params: []", ""].join("\n"), + "utf-8", + ); + // Provider yaml only needs a valid id/kind/module for the contributions loader + // (the configured-check reads its id; the module need not be functional here). + fs.writeFileSync(path.join(root, "provider.mjs"), "export default {};\n", "utf-8"); + fs.writeFileSync( + path.join(root, "providers", `${PROVIDER_ID}.yaml`), + [ + `id: ${PROVIDER_ID}`, + "kind: memory", + "module: ../provider.mjs", + "config:", + " mode: { type: enum, values: [external, managed, managed-external-postgres], default: external }", + " externalUrl: { type: string, optional: true }", + "activation:", + " requiresConfig: [externalUrl]", + "", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(root, ".pack-meta.yaml"), + [ + "sourceUrl: e2e", + "sourceRef: local", + "commit: test", + `packName: ${PACK_NAME}`, + "version: 1.0.0", + "installedAt: '2026-01-01T00:00:00.000Z'", + "updatedAt: '2026-01-01T00:00:00.000Z'", + "scope: server", + "", + ].join("\n"), + "utf-8", + ); +} + +/** Seed (or clear) the provider config in the pack-scoped store. */ +function seedConfig(bobbitDir: string, config: Record | null): void { + const dir = path.join(bobbitDir, "state", "ext-store", PACK_NAME); + const file = path.join(dir, `${encodeStoreKey(CONFIG_STORE_KEY)}.json`); + if (config === null) { fs.rmSync(file, { force: true }); return; } + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ v: 1, value: config }), "utf-8"); +} + +/** GET the effective disabled-entity refs for the pack at server scope. */ +async function effectiveDisabled(): Promise> { + const resp = await apiFetch(`/api/marketplace/pack-activation?scope=server&packName=${PACK_NAME}`); + const text = await resp.text(); + expect(resp.status, text).toBe(200); + return (JSON.parse(text) as { disabled: Record }).disabled; +} + +/** PUT the pack's disabled-entity refs at server scope. */ +async function putActivation(disabled: Record): Promise { + const resp = await apiFetch("/api/marketplace/pack-activation", { + method: "PUT", + body: JSON.stringify({ scope: "server", packName: PACK_NAME, disabled }), + }); + const text = await resp.text(); + expect(resp.status, text).toBe(200); +} + +async function installedRow(): Promise | undefined> { + const resp = await apiFetch("/api/marketplace/installed"); + expect(resp.status).toBe(200); + const body = await resp.json(); + return (body.installed as Array>).find((p) => p.packName === PACK_NAME); +} + +const isAllEnabled = (d: Record): boolean => + Object.keys(d).every((k) => (d[k] ?? []).length === 0); + +test.describe.configure({ mode: "serial" }); + +test.describe("default-disabled pack resolution (server-side)", () => { + let bobbitDir: string; + + test.beforeAll(async ({ gateway }) => { + bobbitDir = gateway.bobbitDir; + installPack(bobbitDir); + }); + + test.afterAll(() => { + fs.rmSync(path.join(bobbitDir, ".bobbit", "config", "market-packs", PACK_NAME), { recursive: true, force: true }); + seedConfig(bobbitDir, null); + }); + + test("installed payload exposes defaultDisabled + requiresGuidedSetup", async () => { + const row = await installedRow(); + expect(row, "pack appears in the installed payload").toBeTruthy(); + expect(row!.defaultDisabled).toBe(true); + expect(row!.requiresGuidedSetup).toBe(true); + }); + + test("fresh (unconfigured, untouched) ⇒ resolves DISABLED (all entities)", async () => { + seedConfig(bobbitDir, null); // not configured + const disabled = await effectiveDisabled(); + // The synthesized overlay disables every contributed entity (tool + provider). + expect(disabled.tools, "tool de-activated").toContain(TOOL_NAME); + expect(disabled.providers, "provider de-activated").toContain(PROVIDER_ID); + expect(isAllEnabled(disabled)).toBe(false); + }); + + test("already configured (externalUrl) ⇒ resolves ENABLED (live-setup preservation)", async () => { + seedConfig(bobbitDir, { mode: "external", externalUrl: "http://localhost:9177" }); + expect(isAllEnabled(await effectiveDisabled()), "configured ⇒ all enabled").toBe(true); + }); + + test("managed mode ⇒ resolves ENABLED even without externalUrl", async () => { + seedConfig(bobbitDir, { mode: "managed" }); + expect(isAllEnabled(await effectiveDisabled())).toBe(true); + }); + + test("explicit enable (force marker) ⇒ ENABLED even when NOT configured, and persists over config clear", async () => { + seedConfig(bobbitDir, null); // clear config first + // PUT all-enabled = explicit user enable → force-enable marker recorded. + await putActivation({ roles: [], tools: [], skills: [], entrypoints: [], providers: [], runtimes: [] }); + expect(isAllEnabled(await effectiveDisabled()), "explicit enable wins over default-disabled").toBe(true); + // Still enabled with NO config — proves it's the marker, not the configured rule. + seedConfig(bobbitDir, null); + expect(isAllEnabled(await effectiveDisabled())).toBe(true); + }); + + test("explicit disable ⇒ DISABLED even when configured (explicit choice wins)", async () => { + seedConfig(bobbitDir, { mode: "external", externalUrl: "http://localhost:9177" }); + await putActivation({ tools: [TOOL_NAME], providers: [PROVIDER_ID] }); + const disabled = await effectiveDisabled(); + expect(disabled.tools).toContain(TOOL_NAME); + expect(disabled.providers).toContain(PROVIDER_ID); + }); +}); diff --git a/tests/e2e/pack-runtimes-api.spec.ts b/tests/e2e/pack-runtimes-api.spec.ts new file mode 100644 index 000000000..0dfe89aef --- /dev/null +++ b/tests/e2e/pack-runtimes-api.spec.ts @@ -0,0 +1,450 @@ +/** + * Pack managed-runtime (Docker-backed) REST API E2E tests. + * + * Exercises the /api/pack-runtimes/* surface wired in server.ts with the Docker + * layer FULLY MOCKED: a fake PackRuntimeSupervisor is injected via the + * registerPackRuntimeSupervisorFactory() seam, so NO Docker daemon is involved. + * + * Coverage: + * - GET /api/pack-runtimes → { runtimes } with round-trippable ids + * - POST /api/pack-runtimes/:id/start → running status (mode forwarded) + * - POST /api/pack-runtimes/:id/stop → stopped status + * - POST /api/pack-runtimes/:id/restart → running status + * - GET /api/pack-runtimes/:id/logs?tail= → { logs } (tail clamped/validated) + * - malformed id → 400 + * - unknown runtime → 404 (supervisor PACK_RUNTIME_NOT_FOUND) + * - malformed mode / tail → 400 + */ +import { test, expect } from "./in-process-harness.js"; +import { apiFetch } from "./e2e-setup.js"; +import { + clampTail, + encodePackRuntimeId, + PackRuntimeNotFoundError, + PackRuntimeDockerUnavailableError, +} from "../../dist/server/runtimes/index.js"; + +// --------------------------------------------------------------------------- +// Fake supervisor (no Docker). One known runtime: demo-pack:web. +// --------------------------------------------------------------------------- + +interface RuntimeStatus { + id: string; + packId: string; + packName?: string; + runtimeId: string; + status: string; + mode?: string; + composeProject: string; + message?: string; +} + +interface CapabilitySummary { + id: string; + packId: string; + runtimeId: string; + mode: string; + startPolicy: string; + composeProject: string; + services: string[]; + images: string[]; + ports: Array<{ key: string; env?: string; container?: number; host?: number }>; + volumePath?: string; + trust: string; +} + +const KNOWN = { packId: "demo-pack", runtimeId: "web", packName: "Demo Pack" }; +const KNOWN_PROJECT = "bobbit-pack-demo-pack-testsuffix"; + +function notFound(): Error { + return new PackRuntimeNotFoundError("no such pack runtime"); +} + +function isKnown(packId: string, runtimeId: string): boolean { + return packId === KNOWN.packId && runtimeId === KNOWN.runtimeId; +} + +/** Call log so tests can assert what the routes forwarded to the supervisor. */ +const calls: Array<{ op: string; packId: string; runtimeId: string; opts?: unknown }> = []; + +function baseStatus(status: string, mode?: string): RuntimeStatus { + return { + id: `${KNOWN.packId}:${KNOWN.runtimeId}`, + packId: KNOWN.packId, + packName: KNOWN.packName, + runtimeId: KNOWN.runtimeId, + status, + mode, + composeProject: KNOWN_PROJECT, + }; +} + +const fakeSupervisor = { + async list() { + return [baseStatus("stopped")]; + }, + async status(packId: string, runtimeId: string) { + if (!isKnown(packId, runtimeId)) throw notFound(); + return baseStatus("stopped"); + }, + async start(packId: string, runtimeId: string, opts?: { mode?: string }) { + calls.push({ op: "start", packId, runtimeId, opts }); + if (!isKnown(packId, runtimeId)) throw notFound(); + return baseStatus("running", opts?.mode ?? "default"); + }, + async stop(packId: string, runtimeId: string, opts?: unknown) { + calls.push({ op: "stop", packId, runtimeId, opts }); + if (!isKnown(packId, runtimeId)) throw notFound(); + return baseStatus("stopped"); + }, + async restart(packId: string, runtimeId: string, opts?: { mode?: string }) { + calls.push({ op: "restart", packId, runtimeId, opts }); + if (!isKnown(packId, runtimeId)) throw notFound(); + return baseStatus("running", opts?.mode ?? "default"); + }, + async logs(packId: string, runtimeId: string, opts?: { tail?: number }) { + // Mirror the real supervisor: tail validation/clamping is the supervisor's + // responsibility (a non-numeric tail throws → REST maps to 400). Validate + // BEFORE recording the call so the malformed-tail case shows no log read. + const tail = clampTail(opts?.tail); + calls.push({ op: "logs", packId, runtimeId, opts }); + if (!isKnown(packId, runtimeId)) throw notFound(); + return `web | started\nweb | tail=${tail}`; + }, + async down(packId: string, runtimeId: string, opts?: { volumes?: boolean; removeState?: boolean }) { + calls.push({ op: "down", packId, runtimeId, opts }); + if (!isKnown(packId, runtimeId)) throw notFound(); + return baseStatus("stopped"); + }, + async capabilitySummary(packId: string, runtimeId: string, opts?: { mode?: string }): Promise { + calls.push({ op: "capabilities", packId, runtimeId, opts }); + if (!isKnown(packId, runtimeId)) throw notFound(); + const mode = opts?.mode ?? "managed-postgres"; + const services = mode === "external-postgres" ? ["api", "web"] : ["api", "web", "db"]; + return { + id: `${KNOWN.packId}:${KNOWN.runtimeId}`, + packId: KNOWN.packId, + runtimeId: KNOWN.runtimeId, + mode, + startPolicy: "on-enable", + composeProject: KNOWN_PROJECT, + services, + images: [...services], + ports: [{ key: "HINDSIGHT_API_PORT", env: "HINDSIGHT_API_PORT", container: 8080, host: 48080 }], + volumePath: "~/.hindsight", + trust: "store and recall agent memory", + }; + }, +}; + +// The real, merged URL-safe id encoding (encodeURIComponent(packId):runtimeId). +function encodeId(packId: string, runtimeId: string): string { + return encodePackRuntimeId(packId, runtimeId); +} + +test.describe("Pack runtimes REST API", () => { + test.beforeAll(async () => { + const mod = await import("../../dist/server/server.js"); + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + }); + + test.afterAll(async () => { + const mod = await import("../../dist/server/server.js"); + mod.registerPackRuntimeSupervisorFactory(null); + }); + + test.beforeEach(() => { + calls.length = 0; + }); + + test("GET /api/pack-runtimes lists runtimes with round-trippable ids @smoke", async () => { + const res = await apiFetch("/api/pack-runtimes"); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.runtimes)).toBe(true); + expect(data.runtimes.length).toBe(1); + const rt = data.runtimes[0]; + expect(rt.packId).toBe(KNOWN.packId); + expect(rt.runtimeId).toBe(KNOWN.runtimeId); + expect(rt.status).toBe("stopped"); + // id is re-derived by the route → decodes back to {packId, runtimeId}. + expect(rt.id).toBe(encodeId(KNOWN.packId, KNOWN.runtimeId)); + }); + + test("POST start returns running status and forwards mode", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/start`, { + method: "POST", + body: JSON.stringify({ mode: "external" }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("running"); + expect(data.mode).toBe("external"); + expect(data.id).toBe(id); + const startCall = calls.find((c) => c.op === "start"); + expect((startCall?.opts as { mode?: string })?.mode).toBe("external"); + }); + + test("POST stop returns stopped status", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/stop`, { method: "POST" }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("stopped"); + expect(calls.some((c) => c.op === "stop")).toBe(true); + }); + + test("POST restart returns running status", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/restart`, { method: "POST" }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("running"); + expect(calls.some((c) => c.op === "restart")).toBe(true); + }); + + test("GET logs returns text and forwards tail", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/logs?tail=50`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(typeof data.logs).toBe("string"); + expect(data.logs).toContain("tail=50"); + const logCall = calls.find((c) => c.op === "logs"); + expect((logCall?.opts as { tail?: number })?.tail).toBe(50); + }); + + test("malformed runtime id → 400", async () => { + // '%21%21' decodes to '!!' which is not valid base64url. + const res = await apiFetch("/api/pack-runtimes/%21%21/start", { method: "POST" }); + expect(res.status).toBe(400); + }); + + test("unknown runtime → 404", async () => { + const id = encodeId("ghost-pack", "nope"); + const res = await apiFetch(`/api/pack-runtimes/${id}/start`, { method: "POST" }); + expect(res.status).toBe(404); + }); + + test("malformed mode → 400 (no supervisor call)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/start`, { + method: "POST", + body: JSON.stringify({ mode: "" }), + }); + expect(res.status).toBe(400); + expect(calls.some((c) => c.op === "start")).toBe(false); + }); + + test("POST start with a malformed JSON body → 400 (no start)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/start`, { + method: "POST", + body: "{not valid json", + }); + // A non-empty but unparseable body must NOT be silently coerced to `{}` and + // mutate the default mode — it is a client error and the supervisor is never + // invoked. + expect(res.status).toBe(400); + expect(calls.some((c) => c.op === "start")).toBe(false); + }); + + test("POST restart with a malformed JSON body → 400 (no restart)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/restart`, { + method: "POST", + body: "not-json-at-all", + }); + expect(res.status).toBe(400); + expect(calls.some((c) => c.op === "restart")).toBe(false); + }); + + test("POST start with an empty body still uses the default mode (200)", async () => { + // An EMPTY body is valid (no mode override) and must not be rejected. + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/start`, { method: "POST" }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("running"); + expect(data.mode).toBe("default"); + }); + + test("clearing the factory (null) drops the cached mock — no stale supervisor", async () => { + const mod = await import("../../dist/server/server.js"); + const SENTINEL = "sentinel-pack-xyz"; + mod.registerPackRuntimeSupervisorFactory(() => ({ + ...fakeSupervisor, + async list() { + return [{ ...baseStatus("running"), packId: SENTINEL, runtimeId: "r" }]; + }, + })); + try { + const seen = await apiFetch("/api/pack-runtimes"); + expect(seen.status).toBe(200); + const seenData = await seen.json(); + expect(seenData.runtimes.some((r: { packId: string }) => r.packId === SENTINEL)).toBe(true); + + // Clear the factory — the previously-served mock must NOT linger in the + // server closure. The route reverts to the production supervisor (or none + // when unwired). The mock returns 200 + sentinel and never throws, so the + // sole invariant is that the sentinel is no longer served: a 200 must not + // contain it, and any non-200 (503 unwired / 500 from the real supervisor) + // equally proves the stale mock is gone. + mod.registerPackRuntimeSupervisorFactory(null); + const after = await apiFetch("/api/pack-runtimes"); + if (after.status === 200) { + const afterData = await after.json(); + expect(afterData.runtimes.some((r: { packId: string }) => r.packId === SENTINEL)).toBe(false); + } else { + expect(after.status).not.toBe(200); + } + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + } + }); + + test("non-numeric tail → 400 (no log read)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/logs?tail=abc`); + expect(res.status).toBe(400); + expect(calls.some((c) => c.op === "logs")).toBe(false); + }); + + test("out-of-range tail is clamped, not rejected", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/logs?tail=-5`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.logs).toContain("tail=1"); // clampTail(-5) → 1 + }); + + test("GET capabilities returns the consent disclosure (services/ports/volume/policy/trust)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/capabilities?mode=managed-postgres`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.id).toBe(id); + expect(data.mode).toBe("managed-postgres"); + expect(data.startPolicy).toBe("on-enable"); + expect(data.services).toEqual(["api", "web", "db"]); + expect(data.ports[0].env).toBe("HINDSIGHT_API_PORT"); + expect(data.volumePath).toBe("~/.hindsight"); + expect(typeof data.trust).toBe("string"); + const capCall = calls.find((c) => c.op === "capabilities"); + expect((capCall?.opts as { mode?: string })?.mode).toBe("managed-postgres"); + }); + + test("GET capabilities forwards the mode query (external-postgres omits db)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/capabilities?mode=external-postgres`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.services).toEqual(["api", "web"]); + }); + + test("GET capabilities ?mode=external reflects the no-Docker setup (dockerRequired:false, no services/ports)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/capabilities?mode=external`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.mode).toBe("external"); + expect(data.dockerRequired).toBe(false); + expect(data.services).toEqual([]); + expect(data.ports).toEqual([]); + // The supervisor is consulted WITHOUT a runtime mode (descriptor/trust only) — + // external mode never maps onto a managed runtime mode. + const capCall = calls.find((c) => c.op === "capabilities"); + expect((capCall?.opts as { mode?: string })?.mode).toBeUndefined(); + }); + + test("GET capabilities ?mode=managed maps the deployment mode to the managed-postgres runtime mode", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/capabilities?mode=managed`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.dockerRequired).toBe(true); + expect(data.mode).toBe("managed-postgres"); + expect(data.services).toEqual(["api", "web", "db"]); + const capCall = calls.find((c) => c.op === "capabilities"); + expect((capCall?.opts as { mode?: string })?.mode).toBe("managed-postgres"); + }); + + test("GET capabilities for an unknown runtime → 404", async () => { + const id = encodeId("ghost-pack", "nope"); + const res = await apiFetch(`/api/pack-runtimes/${id}/capabilities`); + expect(res.status).toBe(404); + }); + + test("POST down (default) forwards volumes:false/removeState:false — the uninstall primitive", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/down`, { method: "POST" }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("stopped"); + expect(data.id).toBe(id); + const downCall = calls.find((c) => c.op === "down"); + expect((downCall?.opts as { volumes?: boolean })?.volumes).toBe(false); + expect((downCall?.opts as { removeState?: boolean })?.removeState).toBe(false); + }); + + test("POST down { volumes:true, removeState:true } forwards the explicit purge flags", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/down`, { + method: "POST", + body: JSON.stringify({ volumes: true, removeState: true }), + }); + expect(res.status).toBe(200); + const downCall = calls.find((c) => c.op === "down"); + expect((downCall?.opts as { volumes?: boolean })?.volumes).toBe(true); + expect((downCall?.opts as { removeState?: boolean })?.removeState).toBe(true); + }); + + test("POST down reads projectId from the QUERY string (client/server consistency, finding #3)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + // The client sends projectId on the query string; the server must read it there + // (not the JSON body). A body-only projectId would be silently dropped. + const res = await apiFetch(`/api/pack-runtimes/${id}/down?projectId=proj-xyz`, { + method: "POST", + body: JSON.stringify({ volumes: true, removeState: true }), + }); + expect(res.status).toBe(200); + const downCall = calls.find((c) => c.op === "down"); + expect((downCall?.opts as { projectId?: string })?.projectId).toBe("proj-xyz"); + expect((downCall?.opts as { volumes?: boolean })?.volumes).toBe(true); + }); + + test("POST down with a malformed JSON body → 400 (no down)", async () => { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/down`, { method: "POST", body: "{not json" }); + expect(res.status).toBe(400); + expect(calls.some((c) => c.op === "down")).toBe(false); + }); + + test("POST down for an unknown runtime → 404", async () => { + const id = encodeId("ghost-pack", "nope"); + const res = await apiFetch(`/api/pack-runtimes/${id}/down`, { method: "POST" }); + expect(res.status).toBe(404); + }); + + test("logs docker-unavailable → 200 with docker-unavailable status (not hidden)", async () => { + const mod = await import("../../dist/server/server.js"); + // Swap in a supervisor whose logs() reports Docker missing for this case. + mod.registerPackRuntimeSupervisorFactory(() => ({ + ...fakeSupervisor, + async logs() { + throw new PackRuntimeDockerUnavailableError("docker is not available"); + }, + })); + try { + const id = encodeId(KNOWN.packId, KNOWN.runtimeId); + const res = await apiFetch(`/api/pack-runtimes/${id}/logs?tail=50`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("docker-unavailable"); + expect(data.logs).toBe(""); + } finally { + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + } + }); +}); diff --git a/tests/e2e/pack-runtimes-start-config.spec.ts b/tests/e2e/pack-runtimes-start-config.spec.ts new file mode 100644 index 000000000..aeda10113 --- /dev/null +++ b/tests/e2e/pack-runtimes-start-config.spec.ts @@ -0,0 +1,326 @@ +/** + * API E2E — `/api/pack-runtimes/:id/{start,restart}` deployment-config remap + * (finding #2). + * + * The start/restart routes must DERIVE the saved provider deployment config for + * the pack and remap it onto the runtime's env keys EXACTLY like marketplace + * activation start does (the shared module-level resolveRuntimeStartPlan): + * - `llmApiKey` → HINDSIGHT_API_LLM_API_KEY + * - `externalDatabaseUrl` → HINDSIGHT_API_DATABASE_URL + * - deployment `mode` → runtime manifest mode (managed ⇒ managed-postgres, + * managed-external-postgres ⇒ external-postgres) + * + * To exercise the REAL config-derivation path (not just a fake mode assertion) + * this installs the first-party Hindsight pack at server scope and seeds a + * real-ish persisted provider config into the pack-scoped store, then drives the + * route with a fake supervisor that captures the start opts the route forwarded. + * + * The Docker layer is fully mocked via registerPackRuntimeSupervisorFactory — no + * daemon is involved. Pack install/seed mirrors tests/e2e/hindsight-agent-tools.spec.ts. + */ +import { test as base, expect } from "./in-process-harness.js"; +import { apiFetch } from "./e2e-setup.js"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { encodePackRuntimeId } from "../../dist/server/runtimes/index.js"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); + +const PACK_NAME = "hindsight"; +const PACK_ID = "hindsight"; // pack store namespace + structural id for this first-party pack +const RUNTIME_ID = "hindsight"; // runtimes/hindsight.yaml `id` +const PACK_SRC = path.resolve(__dirname, "..", "..", "market-packs", PACK_NAME); +const CONFIG_STORE_KEY = "provider-config:memory"; // == providerConfigStoreKey("memory") + +// Skip until the pack files + built provider/runtime descriptors are present, so +// the e2e phase stays green before the implementation lands. +const DEPS_READY = + fs.existsSync(path.join(PACK_SRC, "pack.yaml")) && + fs.existsSync(path.join(PACK_SRC, "runtimes", "hindsight.yaml")) && + fs.existsSync(path.join(PACK_SRC, "lib", "provider.mjs")); + +const test = base; +const describe = DEPS_READY ? test.describe : test.describe.skip; + +// ── Fake supervisor capturing the start/restart opts the route forwards ────── +interface StartOpts { projectId?: string; mode?: string; config?: Record } +const calls: Array<{ op: string; packId: string; runtimeId: string; opts?: StartOpts }> = []; + +function statusRow(status: string, mode?: string) { + return { + id: encodePackRuntimeId(PACK_ID, RUNTIME_ID), + packId: PACK_ID, + packName: "Hindsight", + runtimeId: RUNTIME_ID, + status, + mode, + composeProject: `bobbit-pack-${PACK_ID}-test`, + }; +} + +const fakeSupervisor = { + async list() { return [statusRow("stopped")]; }, + async status() { return statusRow("stopped"); }, + async start(packId: string, runtimeId: string, opts?: StartOpts) { + calls.push({ op: "start", packId, runtimeId, opts }); + return statusRow("running", opts?.mode ?? "default"); + }, + async stop(packId: string, runtimeId: string, opts?: StartOpts) { + calls.push({ op: "stop", packId, runtimeId, opts }); + return statusRow("stopped"); + }, + async restart(packId: string, runtimeId: string, opts?: StartOpts) { + calls.push({ op: "restart", packId, runtimeId, opts }); + return statusRow("running", opts?.mode ?? "default"); + }, + async logs() { return ""; }, + async down() { return statusRow("stopped"); }, + async capabilitySummary() { + return { + id: encodePackRuntimeId(PACK_ID, RUNTIME_ID), + packId: PACK_ID, + runtimeId: RUNTIME_ID, + mode: "managed-postgres", + startPolicy: "on-enable", + composeProject: `bobbit-pack-${PACK_ID}-test`, + services: ["api", "db"], + images: ["api", "db"], + ports: [], + trust: "memory", + }; + }, +}; + +function installPack(bobbitDir: string): void { + const packDir = path.join(bobbitDir, ".bobbit", "config", "market-packs", PACK_NAME); + fs.rmSync(packDir, { recursive: true, force: true }); + fs.cpSync(PACK_SRC, packDir, { recursive: true }); + fs.writeFileSync( + path.join(packDir, ".pack-meta.yaml"), + [ + "sourceUrl: e2e", + "sourceRef: local", + "commit: test", + `packName: ${PACK_NAME}`, + "version: 1.0.0", + "installedAt: '2026-01-01T00:00:00.000Z'", + "updatedAt: '2026-01-01T00:00:00.000Z'", + "scope: server", + ].join("\n") + "\n", + "utf-8", + ); +} + +/** Percent-encode every non-alphanumeric byte — mirrors pack-store.ts::encodeKey. */ +function encodeStoreKey(key: string): string { + const bytes = Buffer.from(key, "utf8"); + let out = ""; + for (const b of bytes) { + const isAlnum = (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a); + out += isAlnum ? String.fromCharCode(b) : `%${b.toString(16).toUpperCase().padStart(2, "0")}`; + } + return out; +} + +function seedConfig(bobbitDir: string, config: Record | null): void { + const dir = path.join(bobbitDir, "state", "ext-store", PACK_ID); + const file = path.join(dir, `${encodeStoreKey(CONFIG_STORE_KEY)}.json`); + if (config === null) { fs.rmSync(file, { force: true }); return; } + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ v: 1, value: config }), "utf-8"); +} + +async function setPackActivation(disabled: Record): Promise { + const resp = await apiFetch("/api/marketplace/pack-activation", { + method: "PUT", + body: JSON.stringify({ scope: "server", packName: PACK_NAME, disabled }), + }); + expect(resp.status, await resp.text().catch(() => "")).toBe(200); +} +const ALL_ENABLED = { roles: [], tools: [], skills: [], entrypoints: [], providers: [] }; + +/** Seed provider config AND refresh the activation-filtered registry index. The + * registry overlays persisted store config + applies the provider activation + * gate at INDEX time and caches the result; a direct disk seed afterwards is + * invisible until the cache is dropped. A pack-activation PUT invalidates the + * resolver caches, so the runtime route then sees the seeded deployment config. */ +async function seedAndRefresh(bobbitDir: string, config: Record): Promise { + seedConfig(bobbitDir, config); + await setPackActivation(ALL_ENABLED); +} + +const RUNTIME_API_ID = encodePackRuntimeId(PACK_ID, RUNTIME_ID); + +describe.configure({ mode: "serial" }); + +describe("pack-runtimes start/restart derives + remaps the saved deployment config", () => { + let bobbitDir: string; + + test.beforeAll(async ({ gateway }) => { + bobbitDir = gateway.bobbitDir; + installPack(bobbitDir); + const mod = await import("../../dist/server/server.js"); + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor); + // PUT pack-activation invalidates resolver caches so the freshly-installed + // pack (and its `memory` provider) is resolvable by the runtime routes. + await setPackActivation(ALL_ENABLED); + }); + + test.afterAll(async () => { + const mod = await import("../../dist/server/server.js"); + mod.registerPackRuntimeSupervisorFactory(null); + seedConfig(bobbitDir, null); + const packDir = path.join(bobbitDir, ".bobbit", "config", "market-packs", PACK_NAME); + fs.rmSync(packDir, { recursive: true, force: true }); + }); + + test.beforeEach(() => { calls.length = 0; }); + + test("the Hindsight pack + memory provider resolve for the runtime routes", async () => { + const res = await apiFetch("/api/ext/contributions"); + expect(res.status).toBe(200); + const data = await res.json(); + const pack = (data.packs as Array<{ packId: string; packName: string }>).find((p) => p.packName === PACK_NAME); + expect(pack, "hindsight pack is registered after install").toBeTruthy(); + expect(pack!.packId).toBe(PACK_ID); + }); + + test("managed mode → managed-postgres + llmApiKey remapped to HINDSIGHT_API_LLM_API_KEY", async () => { + await seedAndRefresh(bobbitDir, { + mode: "managed", + llmApiKey: "sk-managed-key", + dataDir: "/tmp/hs-data", + bank: "bobbit", + }); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/start`, { method: "POST" }); + expect(res.status, await res.text().catch(() => "")).toBe(200); + const startCall = calls.find((c) => c.op === "start"); + expect(startCall, "route forwarded a start to the supervisor").toBeTruthy(); + // Deployment "managed" maps to the runtime manifest mode. + expect(startCall!.opts?.mode).toBe("managed-postgres"); + // The provider's llmApiKey is remapped onto the manifest env key. + expect(startCall!.opts?.config?.HINDSIGHT_API_LLM_API_KEY).toBe("sk-managed-key"); + // The raw deployment fields are still carried in the overlay (placeholder vars). + expect(startCall!.opts?.config?.llmApiKey).toBe("sk-managed-key"); + expect(startCall!.opts?.config?.dataDir).toBe("/tmp/hs-data"); + }); + + test("managed-external-postgres → external-postgres + externalDatabaseUrl remapped to HINDSIGHT_API_DATABASE_URL", async () => { + await seedAndRefresh(bobbitDir, { + mode: "managed-external-postgres", + llmApiKey: "sk-extpg-key", + externalDatabaseUrl: "postgresql://u:p@db.example:5432/hindsight", + bank: "bobbit", + }); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/restart`, { method: "POST" }); + expect(res.status, await res.text().catch(() => "")).toBe(200); + const call = calls.find((c) => c.op === "restart"); + expect(call, "route forwarded a restart to the supervisor").toBeTruthy(); + expect(call!.opts?.mode).toBe("external-postgres"); + expect(call!.opts?.config?.HINDSIGHT_API_DATABASE_URL).toBe("postgresql://u:p@db.example:5432/hindsight"); + expect(call!.opts?.config?.HINDSIGHT_API_LLM_API_KEY).toBe("sk-extpg-key"); + }); + + test("an explicit body mode overrides the deployment-derived mode (config still remapped)", async () => { + await seedAndRefresh(bobbitDir, { mode: "managed", llmApiKey: "sk-override", bank: "bobbit" }); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/start`, { + method: "POST", + body: JSON.stringify({ mode: "external-postgres" }), + }); + expect(res.status).toBe(200); + const startCall = calls.find((c) => c.op === "start"); + // Explicit body mode wins over the plan's mapped managed-postgres. + expect(startCall!.opts?.mode).toBe("external-postgres"); + // The deployment config remap is still applied regardless of the explicit mode. + expect(startCall!.opts?.config?.HINDSIGHT_API_LLM_API_KEY).toBe("sk-override"); + }); + + test("saved EXTERNAL mode + no explicit body mode → 409, never starts Docker", async () => { + await seedAndRefresh(bobbitDir, { mode: "external", externalUrl: "http://localhost:9177", bank: "hermes" }); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/start`, { method: "POST" }); + const body = await res.json().catch(() => ({})); + expect(res.status, JSON.stringify(body)).toBe(409); + expect(body.mode).toBe("external"); + expect(body.started).toBe(false); + // The supervisor must NOT have been asked to start anything. + expect(calls.find((c) => c.op === "start"), "external mode must not start Docker").toBeFalsy(); + }); + + test("saved EXTERNAL mode + restart with no explicit body mode → 409, never restarts Docker", async () => { + await seedAndRefresh(bobbitDir, { mode: "external", externalUrl: "http://localhost:9177", bank: "hermes" }); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/restart`, { method: "POST" }); + const body = await res.json().catch(() => ({})); + expect(res.status, JSON.stringify(body)).toBe(409); + expect(calls.find((c) => c.op === "restart"), "external mode must not restart Docker").toBeFalsy(); + }); + + test("saved EXTERNAL mode + explicit managed body mode → starts the requested managed stack", async () => { + await seedAndRefresh(bobbitDir, { mode: "external", externalUrl: "http://localhost:9177", llmApiKey: "sk-explicit", bank: "bobbit" }); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/start`, { + method: "POST", + body: JSON.stringify({ mode: "managed-postgres" }), + }); + expect(res.status, await res.text().catch(() => "")).toBe(200); + const startCall = calls.find((c) => c.op === "start"); + expect(startCall, "an explicit managed body mode overrides the external saved plan").toBeTruthy(); + expect(startCall!.opts?.mode).toBe("managed-postgres"); + expect(startCall!.opts?.config?.HINDSIGHT_API_LLM_API_KEY).toBe("sk-explicit"); + }); + + // ── Fresh/default Hindsight: DORMANT external-mode provider ────────────── + // With NO persisted provider config, the `memory` provider keeps its schema + // default `mode: external` AND stays dormant (its activation requires + // externalUrl, which is unset). The activation-filtered registry therefore + // DROPS the provider — so the runtime REST/capabilities surface must classify + // the deployment mode from the RAW pack contributions (getRawPack), NOT the + // active-provider list. Reading the active list misclassifies fresh Hindsight + // as provider-less, disclosing/starting the Docker default mode instead of the + // external (no-Docker) setup path. These pin the raw-derivation fix. + + test("fresh/default Hindsight (dormant external provider) → capabilities discloses external/no-Docker setup", async () => { + seedConfig(bobbitDir, null); // no persisted config ⇒ provider dormant (needs externalUrl) + await setPackActivation(ALL_ENABLED); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/capabilities`); + const data = await res.json().catch(() => ({})); + expect(res.status, JSON.stringify(data)).toBe(200); + // External (no-Docker) setup path — not the Docker default-mode disclosure. + expect(data.mode).toBe("external"); + expect(data.dockerRequired).toBe(false); + expect(data.services).toEqual([]); + expect(data.ports).toEqual([]); + }); + + test("fresh/default Hindsight + start with no body mode → 409, never starts Docker", async () => { + seedConfig(bobbitDir, null); + await setPackActivation(ALL_ENABLED); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/start`, { method: "POST" }); + const body = await res.json().catch(() => ({})); + expect(res.status, JSON.stringify(body)).toBe(409); + expect(body.mode).toBe("external"); + expect(body.started).toBe(false); + expect(calls.find((c) => c.op === "start"), "dormant external provider must not start Docker").toBeFalsy(); + }); + + test("fresh/default Hindsight + restart with no body mode → 409, never restarts Docker", async () => { + seedConfig(bobbitDir, null); + await setPackActivation(ALL_ENABLED); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/restart`, { method: "POST" }); + const body = await res.json().catch(() => ({})); + expect(res.status, JSON.stringify(body)).toBe(409); + expect(calls.find((c) => c.op === "restart"), "dormant external provider must not restart Docker").toBeFalsy(); + }); + + test("fresh/default Hindsight + explicit managed body mode → still starts the managed stack", async () => { + seedConfig(bobbitDir, null); + await setPackActivation(ALL_ENABLED); + const res = await apiFetch(`/api/pack-runtimes/${RUNTIME_API_ID}/start`, { + method: "POST", + body: JSON.stringify({ mode: "managed-postgres" }), + }); + expect(res.status, await res.text().catch(() => "")).toBe(200); + const startCall = calls.find((c) => c.op === "start"); + expect(startCall, "an explicit managed mode overrides the dormant external default").toBeTruthy(); + expect(startCall!.opts?.mode).toBe("managed-postgres"); + }); +}); diff --git a/tests/e2e/provider-hook-effective-goal.spec.ts b/tests/e2e/provider-hook-effective-goal.spec.ts index 8c6e01047..9bc380906 100644 --- a/tests/e2e/provider-hook-effective-goal.spec.ts +++ b/tests/e2e/provider-hook-effective-goal.spec.ts @@ -10,10 +10,10 @@ * — a treatment leak across the goal/agent tree. * * This pins the fix: a delegate session (teamGoalId only, no goalId) under a - * goal whose metadata disables the `demo` provider gets an EMPTY before-prompt - * tail (provider filtered via teamGoalId), while an otherwise-identical delegate - * under a metadata-less goal still receives the demo block. The provider is - * enabled globally in both cases, so the only differentiator is the + * goal whose metadata disables the `demo` provider gets EMPTY before-prompt + * content (provider filtered via teamGoalId), while an otherwise-identical + * delegate under a metadata-less goal still receives the demo block. The + * provider is enabled globally in both cases, so the only differentiator is the * teamGoalId-resolved metadata. */ import { test, expect } from "./in-process-harness.js"; @@ -74,13 +74,13 @@ async function createDelegate(parentId: string): Promise> { return resp.json(); } -async function callBeforePrompt(sessionId: string, prompt: string): Promise<{ status: number; tail: string }> { +async function callBeforePrompt(sessionId: string, prompt: string): Promise<{ status: number; content: string }> { const resp = await apiFetch(`/api/sessions/${sessionId}/provider-hooks/before-prompt`, { method: "POST", body: JSON.stringify({ prompt }), }); const body = resp.status === 200 ? await resp.json() : {}; - return { status: resp.status, tail: typeof body.tail === "string" ? body.tail : "" }; + return { status: resp.status, content: typeof body.content === "string" ? body.content : "" }; } test.describe.serial("provider hook endpoints resolve the effective goal (teamGoalId)", () => { @@ -102,7 +102,7 @@ test.describe.serial("provider hook endpoints resolve the effective goal (teamGo if (packDir) fs.rmSync(packDir, { recursive: true, force: true }); }); - test("delegate (teamGoalId only) under a metadata-disabled goal gets an EMPTY tail; metadata-less control still fires", async () => { + test("delegate (teamGoalId only) under a metadata-disabled goal gets EMPTY content; metadata-less control still fires", async () => { // Goal whose metadata disables the demo provider for the whole subtree. const disabledGoal = await createGoalRaw({ title: "hook-disabled", @@ -136,13 +136,13 @@ test.describe.serial("provider hook endpoints resolve the effective goal (teamGo // FIX: the endpoint resolves teamGoalId → goal metadata → demo filtered out. const disabled = await callBeforePrompt(disabledDelegate.id, prompt); expect(disabled.status).toBe(200); - expect(disabled.tail, "demo must be filtered for a delegate whose teamGoalId-goal disables it").toBe(""); + expect(disabled.content, "demo must be filtered for a delegate whose teamGoalId-goal disables it").toBe(""); // Control delegate (metadata-less goal) still receives the demo block — // proves the endpoint itself works and the filtering is goal-metadata-driven // via teamGoalId, not a global outage. const control = await callBeforePrompt(controlDelegate.id, prompt); expect(control.status).toBe(200); - expect(control.tail).toContain(`DEMO_BEFORE_PROMPT ${prompt}`); + expect(control.content).toContain(`DEMO_BEFORE_PROMPT ${prompt}`); }); }); diff --git a/tests/e2e/provider-turn-hooks.spec.ts b/tests/e2e/provider-turn-hooks.spec.ts index bbea55faa..a35c920ea 100644 --- a/tests/e2e/provider-turn-hooks.spec.ts +++ b/tests/e2e/provider-turn-hooks.spec.ts @@ -3,9 +3,8 @@ * * Exercises the gateway surfaces that wire provider lifecycle hooks for a turn: * - POST /api/sessions/:id/provider-hooks/before-prompt (beforePrompt dispatch - * + system-prompt-tail synthesis; the production provider-bridge pi extension - * calls this — here the test plays the bridge because the E2E mock agent does - * not load pi extensions). + * + dynamic-context message content synthesis; the generated provider-bridge + * pi extension turns this into a hidden custom/user-side message). * - GET /api/sessions/:id/context-trace (per-turn diagnostics). * - afterTurn — fired server-side from the agent_end lifecycle seam. * - sessionShutdown — fired server-side from the archive seam. @@ -16,8 +15,8 @@ * specs sharing the worker-scoped in-process gateway). * * NON-NEGOTIABLE invariant pinned here: the user's message text is never mutated - * by the per-turn hooks — recall lands only in the system-prompt tail. The turn - * echoes back the exact submitted bytes. + * by the per-turn hooks — recall lands in a hidden custom/user-side message, not + * the cached system prompt. The turn echoes back the exact submitted bytes. */ import { test, expect } from "./in-process-harness.js"; import { @@ -33,18 +32,14 @@ import { import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; +import { generateProviderBridgeExtension } from "../../dist/server/agent/provider-bridge-extension.js"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); const fixturePackDir = path.resolve(__dirname, "..", "fixtures", "packs", "provider-demo"); const PACK_NAME = "provider-demo"; -// Delimiters that fence the Bobbit dynamic-context region in the system-prompt -// tail. These are the design's idempotency markers — pinned here so a drift in -// the delimiter format is caught by E2E as well as the codegen unit test. -const DYNAMIC_CONTEXT_START = ""; -const DYNAMIC_CONTEXT_END = ""; - interface TraceProviderRow { id: string; ms: number; blocks: number; omitted: number; error?: string } interface TraceEntry { ts: number; hook: string; sessionId: string; providers: TraceProviderRow[] } @@ -77,7 +72,7 @@ async function setProviderDisabled(providers: string[]): Promise { expect(resp.status).toBe(200); } -interface BeforePromptResult { status: number; tail: string; blocks: Array> } +interface BeforePromptResult { status: number; content: string; blocks: Array> } async function callBeforePrompt(sessionId: string, prompt: string): Promise { const resp = await apiFetch(`/api/sessions/${sessionId}/provider-hooks/before-prompt`, { @@ -87,11 +82,28 @@ async function callBeforePrompt(sessionId: string, prompt: string): Promise Promise> { + const source = generateProviderBridgeExtension(sessionId); + const transpiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }); + const file = path.join(tempDir, `provider-bridge-${Date.now()}-${Math.random().toString(16).slice(2)}.cjs`); + fs.writeFileSync(file, transpiled.outputText, "utf-8"); + const mod = await import(pathToFileURL(file).href); + const extensionFactory = typeof mod.default === "function" ? mod.default : mod.default?.default; + expect(typeof extensionFactory, "generated bridge default export").toBe("function"); + const handlers = new Map Promise>(); + extensionFactory({ on: (event: string, handler: (event: any) => Promise) => handlers.set(event, handler) }); + const handler = handlers.get("before_agent_start"); + expect(typeof handler, "generated bridge registered before_agent_start").toBe("function"); + return handler!; +} + async function readContextTrace(sessionId: string, limit?: number): Promise { const qs = typeof limit === "number" ? `?limit=${limit}` : ""; const resp = await apiFetch(`/api/sessions/${sessionId}/context-trace${qs}`); @@ -163,7 +175,7 @@ test.describe("provider per-turn hooks", () => { for (const cwd of cwds.splice(0)) fs.rmSync(cwd, { recursive: true, force: true }); }); - test("beforePrompt injects a delimited system-prompt tail, then afterTurn fires; both appear in the context trace", async () => { + test("beforePrompt returns dynamic-context message content, then afterTurn fires; both appear in the context trace", async () => { // demo + boom enabled; slow disabled so the happy path stays deterministic. await setProviderDisabled(["slow"]); const { id, cwd } = await newSession("happy"); @@ -175,11 +187,10 @@ test.describe("provider per-turn hooks", () => { const before = await callBeforePrompt(id, promptText); expect(before.status).toBe(200); - // The tail is a single delimited dynamic-context region carrying the demo - // block — and it must NOT leak into the user's message text. - expect(before.tail).toContain(DYNAMIC_CONTEXT_START); - expect(before.tail).toContain(DYNAMIC_CONTEXT_END); - expect(before.tail).toContain(`DEMO_BEFORE_PROMPT ${promptText}`); + // The endpoint returns message content carrying the demo block — and it must + // NOT leak into or rewrite the user's message text. + expect(before.content, "beforePrompt must return custom-message content").toContain(" b.id === "demo:turn"); @@ -214,6 +225,44 @@ test.describe("provider per-turn hooks", () => { expect(at!.providers.some((p) => p.id === "demo")).toBe(true); }); + test("generated bridge delivers dynamic context as a hidden custom message and keeps system prompt stable", async () => { + await setProviderDisabled(["slow"]); + const { id, cwd } = await newSession("bridge"); + const beforeAgentStart = await registerGeneratedBeforeAgentStart(id, cwd); + const baseSystemPrompt = "BASE SYSTEM PROMPT\n(sessionSetup dynamic context is already stable here)"; + let piSystemPrompt = baseSystemPrompt; + const prompts = ["turn A cache probe", "turn B cache probe"]; + const systemPromptSnapshots: string[] = []; + const dynamicMessages: any[] = []; + + for (const prompt of prompts) { + const result = await beforeAgentStart({ prompt, systemPrompt: piSystemPrompt }); + expect(result, "before_agent_start must return a hidden bobbit:dynamic-context custom message, not systemPrompt") + .toMatchObject({ + message: { + customType: "bobbit:dynamic-context", + display: false, + }, + }); + expect(result).not.toHaveProperty("systemPrompt"); + expect(result).not.toHaveProperty("prompt"); + expect(result.message.content).toContain(`DEMO_BEFORE_PROMPT ${prompt}`); + dynamicMessages.push({ role: "custom", ...result.message }); + if (typeof result.systemPrompt === "string") piSystemPrompt = result.systemPrompt; + systemPromptSnapshots.push(piSystemPrompt); + } + + expect(dynamicMessages).toEqual([ + expect.objectContaining({ role: "custom", customType: "bobbit:dynamic-context", display: false }), + expect.objectContaining({ role: "custom", customType: "bobbit:dynamic-context", display: false }), + ]); + expect(systemPromptSnapshots, "changing beforePrompt blocks must not change cached system prompt bytes") + .toEqual([baseSystemPrompt, baseSystemPrompt]); + expect(dynamicMessages[0].content).toContain("turn A cache probe"); + expect(dynamicMessages[1].content).toContain("turn B cache probe"); + expect(dynamicMessages[0].content).not.toBe(dynamicMessages[1].content); + }); + test("context-trace honours the limit query param", async () => { await setProviderDisabled(["slow"]); const { id } = await newSession("limit"); @@ -235,7 +284,7 @@ test.describe("provider per-turn hooks", () => { const before = await callBeforePrompt(id, "anything"); expect(before.status).toBe(200); - expect(before.tail).toBe(""); + expect(before.content).toBe(""); expect(before.blocks).toEqual([]); await driveTurn(id, "anything"); @@ -248,7 +297,7 @@ test.describe("provider per-turn hooks", () => { }); }); - test("a hanging provider is bounded by its timeout — endpoint returns an empty tail with a timeout trace row", async () => { + test("a hanging provider is bounded by its timeout — endpoint returns empty content with a timeout trace row", async () => { // Only the slow (hanging) provider is enabled. await setProviderDisabled(["demo", "boom"]); const { id } = await newSession("hang"); @@ -258,7 +307,7 @@ test.describe("provider per-turn hooks", () => { const elapsed = Date.now() - t0; expect(before.status).toBe(200); - expect(before.tail).toBe(""); + expect(before.content).toBe(""); // slow.yaml budget.timeoutMs is 300ms; the endpoint must respond well // within a few seconds rather than the provider's 30s sleep. expect(elapsed).toBeLessThan(5_000); diff --git a/tests/e2e/ui/hindsight-marketplace.spec.ts b/tests/e2e/ui/hindsight-marketplace.spec.ts new file mode 100644 index 000000000..a6dda7b71 --- /dev/null +++ b/tests/e2e/ui/hindsight-marketplace.spec.ts @@ -0,0 +1,444 @@ +/** + * Browser E2E — Hindsight MARKETPLACE surface, redefined by the "Hindsight surfaces + * & embedded dashboard" goal. The Marketplace is the CONFIGURATION HOME (Configure + * form + guided wizard) and the row keeps a read-only derived state (Disabled · + * Dormant · External connected/unreachable · Managed stopped/starting/running). The + * key change this goal lands: **Open Hindsight UI** no longer navigates the browser + * to the dashboard — it opens the EMBEDDED dashboard tab in-app, with a small + * secondary external-browser fallback link. This spec pins: + * + * 1. FIRST-RUN — an unconfigured built-in row is Disabled and surfaces Configure. + * 2. EXTERNAL CONNECTED — a healthy external Hindsight derives External connected; + * Test connection reports ok; the row exposes Open Hindsight UI. + * 3. OPEN HINDSIGHT UI — the primary `market-hindsight-open-ui` is a BUTTON that + * opens the embedded dashboard tab (`hindsight-dashboard-frame` src=uiUrl) WITHOUT + * opening a new browser window/page; a secondary `market-hindsight-open-ui-external` + * anchor carries the uiUrl (target=_blank, rel=noopener) as the fallback. + * 4. INLINE CONFIGURE — the sessionless inline form saves config + persists across + * reload (the config home). + * 5. MANAGED — the row tracks a mocked supervisor stopped→starting→running; loading + * NEVER fires `/start`; explicit consent-gated Start is the only `/start` path. + * 6. lastError object rendering + per-project override (unchanged #820/quality + * invariants preserved on the Marketplace surface). + * + * Runtime is MOCKED via `registerPackRuntimeSupervisorFactory` (no Docker); external + * data is the in-process `hindsight-stub.mjs`. + * + * SKIP-GUARD: a static STACK_READY (the embedded-dashboard panel bundle/descriptor of + * this goal — a reliable proxy that the marketplace changes also merged) gates the + * suite, plus a per-test runtime check that the contribution is served here. Keeps the + * suite green-by-skip until the parallel coder branches land. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test, expect } from "../gateway-harness.js"; +import type { Page } from "@playwright/test"; +import { apiFetch, waitForSessionStatus } from "../e2e-setup.js"; +import { openApp, createSessionViaUI, navigateToHash } from "./ui-helpers.js"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); + +const PACK = "hindsight"; +const DASHBOARD_PANEL_ID = "hindsight.dashboard"; +const PACK_SRC = path.resolve(__dirname, "..", "..", "..", "market-packs", PACK); +const STUB_PATH = path.resolve(__dirname, "..", "hindsight-stub.mjs"); +const CONFIG_KEY = "provider-config:memory"; +const EX_UI_URL = "http://127.0.0.1:19177/banks/hermes?view=data"; + +const STACK_READY = + fs.existsSync(path.join(PACK_SRC, "lib", "HindsightDashboardPanel.js")) && + fs.existsSync(path.join(PACK_SRC, "panels", "hindsight-dashboard.yaml")) && + fs.existsSync(STUB_PATH); + +const describe = STACK_READY ? test.describe : test.describe.skip; + +interface HindsightStub { + url: string; + setHealthy(ok: boolean): void; + seedMemories(bank: string, mem: { text: string; id?: string; score?: number; tags?: string[] }[]): void; + close(): Promise; +} +async function startStub(): Promise { + const mod = await import(STUB_PATH as string); + const start = mod.startHindsightStub ?? mod.default; + return start({ port: 0 }) as Promise; +} + +interface PackContributionsMeta { + packId: string; + panels?: { id: string; title?: string }[]; + entrypoints?: Array<{ id: string; kind: string; routeId?: string; listName: string }>; + routeNames?: string[]; +} +async function listContributions(): Promise { + const res = await apiFetch("/api/ext/contributions"); + if (!res.ok) return []; + return ((await res.json()).packs ?? []) as PackContributionsMeta[]; +} + +/** Runtime readiness: the embedded-dashboard contribution (and config/status routes) + * must be served here — a reliable proxy that the whole goal's stack has merged. */ +async function dashboardContributionReady(): Promise { + const meta = (await listContributions()).find((p) => p.packId === PACK); + if (!meta) return false; + if (!meta.panels?.some((p) => p.id === DASHBOARD_PANEL_ID)) return false; + for (const r of ["config", "status"]) { + if (!meta.routeNames?.includes(r)) return false; + } + const panelRes = await apiFetch(`/api/ext/packs/${PACK}/panels/${encodeURIComponent(DASHBOARD_PANEL_ID)}`); + return panelRes.ok; +} + +async function putHindsightConfig(overrides: Record): Promise { + const { getPackStore } = await import("../../../dist/server/extension-host/pack-store.js"); + await getPackStore().put(PACK, CONFIG_KEY, overrides); +} + +async function resetHindsightActivation(): Promise { + try { + const res = await apiFetch(`/api/marketplace/pack-activation?scope=server&packName=${PACK}`); + if (!res.ok) return; + const cat = ((await res.json()).catalogue ?? {}) as Record; + const arr = (k: string): string[] => + Array.isArray(cat[k]) + ? (cat[k] as Array<{ listName?: string } | string>).map((e) => (typeof e === "string" ? e : e.listName ?? "")).filter(Boolean) + : []; + const disabled = { + roles: arr("roles"), tools: arr("tools"), skills: arr("skills"), entrypoints: arr("entrypoints"), + providers: arr("providers"), hooks: arr("hooks"), mcp: arr("mcp"), piExtensions: arr("piExtensions"), + runtimes: arr("runtimes"), workflows: arr("workflows"), + }; + await apiFetch("/api/marketplace/pack-activation", { + method: "PUT", + body: JSON.stringify({ scope: "server", packName: PACK, disabled }), + }); + } catch { /* best-effort */ } +} + +async function reconcile(page: Page): Promise { + await page.evaluate(() => (window as any).__bobbitReconcilePackRenderers?.()).catch(() => { /* race */ }); +} + +/** Does the `config` route expose the per-project override contract here? */ +async function overrideContractReady(): Promise { + const res = await apiFetch(`/api/ext/pack-route/${PACK}/config?projectId=__probe__`); + if (!res.ok) return false; + const data = (await res.json().catch(() => ({}))) as Record; + return Object.prototype.hasOwnProperty.call(data, "globalConfig") || Object.prototype.hasOwnProperty.call(data, "projectOverride"); +} + +async function openWithSession(page: Page): Promise { + await openApp(page); + const sid = await createSessionViaUI(page); + expect(sid, "a session must be selected so the marketplace can read pack status").toBeTruthy(); + await waitForSessionStatus(sid, "idle").catch(() => { /* best-effort */ }); + await reconcile(page); +} + +async function openMarketRow(page: Page): Promise> { + await navigateToHash(page, "#/roles"); + await navigateToHash(page, "#/market"); + await expect(page.locator('[data-testid="market-installed-panel"]')).toBeVisible({ timeout: 15_000 }); + await page.locator('[data-testid="market-tab-installed"]').click(); + const row = page.locator('[data-testid="market-installed-pack"][data-pack-name="hindsight"]').first(); + await expect(row, "the built-in Hindsight row is present").toBeVisible({ timeout: 15_000 }); + return row; +} + +const stateBadge = (row: ReturnType) => row.locator('[data-testid="market-hindsight-state"]'); +const dashboardFrame = (page: Page) => page.locator('[data-testid="hindsight-dashboard-frame"]').first(); + +// ── Mocked managed runtime supervisor (NO Docker). ── +interface SupCall { op: "start" | "stop" | "restart" | "down"; } +const supCalls: SupCall[] = []; +let managedRuntimeStatus: "stopped" | "starting" | "running" | "unhealthy" | "docker-unavailable" = "stopped"; +let stubPort = 0; +function rtStatus(status: string) { + return { id: "hindsight:hindsight", packId: PACK, packName: PACK, runtimeId: "hindsight", status, mode: "managed-postgres", composeProject: "bobbit-pack-hindsight-test" }; +} +const fakeSupervisor = { + async list() { return [rtStatus(managedRuntimeStatus)]; }, + async status() { return rtStatus(managedRuntimeStatus); }, + async start() { supCalls.push({ op: "start" }); managedRuntimeStatus = "running"; return rtStatus("running"); }, + async stop() { supCalls.push({ op: "stop" }); managedRuntimeStatus = "stopped"; return rtStatus("stopped"); }, + async restart() { supCalls.push({ op: "restart" }); managedRuntimeStatus = "running"; return rtStatus("running"); }, + async down() { supCalls.push({ op: "down" }); managedRuntimeStatus = "stopped"; return rtStatus("stopped"); }, + async logs() { return "managed-runtime log line\n"; }, + async capabilitySummary() { + return { + ...rtStatus(managedRuntimeStatus), + startPolicy: "on-enable", + services: ["api", "db"], + images: ["hindsight/api", "postgres"], + ports: [{ key: "API_PORT", host: stubPort, container: 8000 }], + volumePath: "~/.hindsight", + trust: "local", + }; + }, +}; + +test.describe.configure({ mode: "serial" }); + +describe("Hindsight pack — Marketplace state + actions (config home + embedded Open UI)", () => { + let stub: HindsightStub; + let ready = false; + + test.beforeAll(async () => { + const mod = await import("../../../dist/server/server.js"); + mod.registerPackRuntimeSupervisorFactory(() => fakeSupervisor as never); + stub = await startStub(); + stubPort = Number(new URL(stub.url).port); + ready = await dashboardContributionReady(); + }); + + test.afterAll(async () => { + const mod = await import("../../../dist/server/server.js"); + mod.registerPackRuntimeSupervisorFactory(null); + await putHindsightConfig({}); + await resetHindsightActivation(); + if (stub) await stub.close().catch(() => { /* ignore */ }); + }); + + test.beforeEach(async () => { + await putHindsightConfig({}); + await resetHindsightActivation(); + supCalls.length = 0; + managedRuntimeStatus = "stopped"; + stub.setHealthy(true); + }); + + test("first-run: the built-in row shows Disabled and surfaces Configure as the primary setup path", async ({ page }) => { + test.skip(!ready, "Hindsight embedded-dashboard/marketplace stack not served in this environment"); + await openWithSession(page); + const row = await openMarketRow(page); + + await expect(stateBadge(row), "an unconfigured built-in Hindsight row is Disabled").toHaveAttribute("data-state", "disabled", { timeout: 20_000 }); + + const configure = row.locator('[data-testid="market-hindsight-configure"]'); + await expect(configure, "Configure is offered as the primary setup path").toBeVisible(); + await expect(configure).toBeEnabled(); + }); + + test("external connected: row state and Test connection", async ({ page }) => { + test.skip(!ready, "Hindsight embedded-dashboard/marketplace stack not served in this environment"); + await putHindsightConfig({ externalUrl: stub.url, bank: "hermes", uiUrl: EX_UI_URL }); + + await openWithSession(page); + const row = await openMarketRow(page); + + await expect(stateBadge(row), "a healthy external Hindsight is External connected").toHaveAttribute("data-state", "external-connected", { timeout: 20_000 }); + + const summary = row.locator('[data-testid="market-hindsight-config"]'); + await expect(summary).toBeVisible({ timeout: 15_000 }); + await expect(summary).toContainText("hermes"); + + await row.locator('[data-testid="market-hindsight-test"]').click(); + await expect(row.locator('[data-testid="market-hindsight-action-result"]'), "Test connection reports a result lozenge").toBeVisible({ timeout: 20_000 }); + await expect(row.locator('[data-testid="market-hindsight-action-result"]')).toContainText("Connected"); + }); + + test("Open Hindsight UI opens the EMBEDDED dashboard tab (no new window); the external link is the fallback", async ({ page }) => { + test.skip(!ready, "Hindsight embedded-dashboard/marketplace stack not served in this environment"); + // Distinct data-plane URL + human dashboard UI URL. + await putHindsightConfig({ externalUrl: stub.url, bank: "hermes", uiUrl: EX_UI_URL }); + expect(EX_UI_URL).not.toBe(stub.url); + + // Keep the embed warning out of the way: a generous iframe load-timeout. + await page.addInitScript(() => { (window as any).__bobbitHindsightIframeTimeoutMs = 60_000; }); + + await openWithSession(page); + const row = await openMarketRow(page); + await expect(stateBadge(row)).toHaveAttribute("data-state", "external-connected", { timeout: 20_000 }); + + // The primary Open Hindsight UI is an in-app route link (not an external + // target=_blank/window.open escape hatch). + const openUi = row.locator('[data-testid="market-hindsight-open-ui"]'); + await expect(openUi, "Open Hindsight UI is surfaced with a configured UI URL").toBeVisible({ timeout: 15_000 }); + await expect(openUi).toHaveAttribute("href", /#\/ext\/hindsight$/); + await expect(openUi).not.toHaveAttribute("target", "_blank"); + + // The secondary external-browser fallback carries the uiUrl verbatim. + const external = row.locator('[data-testid="market-hindsight-open-ui-external"]'); + await expect(external, "a secondary external-browser fallback link exists").toBeVisible({ timeout: 15_000 }); + await expect(external).toHaveAttribute("href", EX_UI_URL); + await expect(external).toHaveAttribute("target", "_blank"); + await expect(external).toHaveAttribute("rel", /noopener/); + + // Clicking the primary opens the embedded dashboard tab IN-APP — no new window/page. + const popups: unknown[] = []; + page.on("popup", (p) => popups.push(p)); + const pagesBefore = page.context().pages().length; + await openUi.click(); + + const fr = dashboardFrame(page); + await expect(fr, "Open Hindsight UI opens the embedded dashboard tab").toBeVisible({ timeout: 20_000 }); + await expect(fr, "the embedded iframe loads the configured UI URL verbatim").toHaveAttribute("src", EX_UI_URL, { timeout: 15_000 }); + expect(popups, "Open Hindsight UI must NOT open a new browser window").toHaveLength(0); + expect(page.context().pages().length, "no extra browser page is created").toBe(pagesBefore); + }); + + test("inline Configure form saves config sessionlessly and persists across reload (the config home)", async ({ page }) => { + test.skip(!ready, "Hindsight embedded-dashboard/marketplace stack not served in this environment"); + await openWithSession(page); + let row = await openMarketRow(page); + await expect(stateBadge(row), "an unconfigured default-disabled row starts Disabled").toHaveAttribute("data-state", "disabled", { timeout: 20_000 }); + + await row.locator('[data-testid="market-hindsight-configure"]').click(); + const form = row.locator('[data-testid="market-hindsight-config-form"]'); + await expect(form, "Configure opens the inline config form").toBeVisible({ timeout: 15_000 }); + await expect(form.locator('[data-testid="market-hindsight-form-mode"]')).toBeVisible({ timeout: 15_000 }); + + await form.locator('[data-testid="market-hindsight-form-externalurl"]').fill(stub.url); + await form.locator('[data-testid="market-hindsight-form-bank"]').fill("hermes"); + await form.locator('[data-testid="market-hindsight-form-uiurl"]').fill(EX_UI_URL); + await form.locator('[data-testid="market-hindsight-form-recallscope"]').selectOption("all"); + expect(EX_UI_URL).not.toBe(stub.url); + + await expect(form.locator('[data-testid="market-hindsight-config-dirty"]'), "dirty edits show an unsaved indicator before Save").toBeVisible({ timeout: 15_000 }); + const recallField = form.locator('[data-testid="market-hindsight-field-recallscope"]'); + await expect(recallField, "changed recall scope row is marked dirty").toHaveAttribute("data-dirty", "true"); + await expect(form.locator('[data-testid="market-hindsight-field-changed-recallScope"]'), "changed recall scope label is shown").toBeVisible(); + + await form.locator('[data-testid="market-hindsight-config-save"]').click(); + await expect(form.locator('[data-testid="market-hindsight-config-result"]'), "save reports a result").toContainText("Saved", { timeout: 20_000 }); + await expect(form.locator('[data-testid="market-hindsight-config-dirty"]'), "dirty indicator clears after Save").toHaveCount(0, { timeout: 15_000 }); + await expect(recallField, "recall scope row is no longer marked dirty after Save").toHaveAttribute("data-dirty", "false", { timeout: 15_000 }); + + // Reload the page entirely — the persisted config must survive (sessionless read). + await page.reload(); + row = await openMarketRow(page); + + const summary = row.locator('[data-testid="market-hindsight-config"]'); + await expect(summary, "the saved config surfaces after reload").toBeVisible({ timeout: 20_000 }); + await expect(summary).toContainText("hermes"); + + // Re-open Configure: the saved recall scope persists and no dirty state is shown. + await row.locator('[data-testid="market-hindsight-configure"]').click(); + const reloadedForm = row.locator('[data-testid="market-hindsight-config-form"]'); + await expect(reloadedForm.locator('[data-testid="market-hindsight-form-recallscope"]'), "saved recall scope persists across reload").toHaveValue("all", { timeout: 15_000 }); + await expect(reloadedForm.locator('[data-testid="market-hindsight-config-dirty"]'), "reload opens with no unsaved changes").toHaveCount(0); + + // The external fallback link reflects the saved (distinct) UI URL verbatim. + const external = row.locator('[data-testid="market-hindsight-open-ui-external"]'); + await expect(external, "the external fallback link surfaces the saved UI URL").toBeVisible({ timeout: 15_000 }); + await expect(external).toHaveAttribute("href", EX_UI_URL); + }); + + test("managed: the row tracks mocked runtime status (stopped→starting→running) and loading never starts Docker", async ({ page }) => { + test.skip(!ready, "Hindsight embedded-dashboard/marketplace stack not served in this environment"); + await putHindsightConfig({ mode: "managed", llmApiKey: "sk-managed-test" }); + managedRuntimeStatus = "stopped"; + + const startRequests: string[] = []; + page.on("request", (r) => { + if (/\/api\/pack-runtimes\/[^/]+\/start(\?|$)/.test(r.url())) startRequests.push(r.url()); + }); + + await openWithSession(page); + let row = await openMarketRow(page); + + await expect(stateBadge(row), "a configured-but-stopped managed runtime is Managed stopped").toHaveAttribute("data-state", "managed-stopped", { timeout: 20_000 }); + await expect(row.locator('[data-testid="market-hindsight-start"]'), "Managed stopped shows a Start action").toBeVisible(); + expect(startRequests, "loading the marketplace must NOT start Docker (status reads are pure)").toHaveLength(0); + expect(supCalls.filter((c) => c.op === "start"), "no supervisor.start on load").toHaveLength(0); + + managedRuntimeStatus = "starting"; + row = await openMarketRow(page); + await expect(stateBadge(row), "row tracks the mocked starting status").toHaveAttribute("data-state", "managed-starting", { timeout: 20_000 }); + await expect(row.locator('[data-testid="market-hindsight-stop"]'), "a starting runtime offers Stop").toBeVisible(); + + managedRuntimeStatus = "running"; + row = await openMarketRow(page); + await expect + .poll(async () => (await stateBadge(row).getAttribute("data-state")) ?? "", { timeout: 20_000 }) + .toMatch(/^managed-(running|unhealthy)$/); + await expect(row.locator('[data-testid="market-hindsight-start"]'), "a running runtime no longer offers Start").toHaveCount(0); + await expect(row.locator('[data-testid="market-hindsight-stop"]'), "a running runtime offers Stop").toBeVisible(); + + expect(startRequests, "status polling/rendering never starts Docker").toHaveLength(0); + expect(supCalls.filter((c) => c.op === "start"), "the supervisor.start was never called by reads").toHaveLength(0); + }); + + test("managed: explicit consent-gated Start is the only path that calls /start (exactly once)", async ({ page }) => { + test.skip(!ready, "Hindsight embedded-dashboard/marketplace stack not served in this environment"); + await putHindsightConfig({ mode: "managed", llmApiKey: "sk-managed-test" }); + managedRuntimeStatus = "stopped"; + + const startRequests: string[] = []; + page.on("request", (r) => { + if (/\/api\/pack-runtimes\/[^/]+\/start(\?|$)/.test(r.url())) startRequests.push(r.url()); + }); + + await openWithSession(page); + const row = await openMarketRow(page); + await expect(stateBadge(row)).toHaveAttribute("data-state", "managed-stopped", { timeout: 20_000 }); + + await row.locator('[data-testid="market-hindsight-start"]').click(); + await expect(row.locator('[data-testid="market-hindsight-start-consent"]'), "Start opens the consent disclosure first").toBeVisible({ timeout: 15_000 }); + expect(startRequests, "opening the consent card must not start Docker").toHaveLength(0); + + const confirm = row.locator('[data-testid="market-hindsight-start-confirm"]'); + await expect(confirm).toBeVisible(); + const [startReq] = await Promise.all([ + page.waitForRequest(/\/api\/pack-runtimes\/[^/]+\/start(\?|$)/, { timeout: 20_000 }), + confirm.click(), + ]); + expect(startReq.url()).toMatch(/\/api\/pack-runtimes\/[^/]+\/start/); + await expect(row.locator('[data-testid="market-hindsight-action-result"]')).toBeVisible({ timeout: 20_000 }); + expect(startRequests, "exactly one explicit /start request").toHaveLength(1); + expect(supCalls.filter((c) => c.op === "start"), "the supervisor.start fired exactly once").toHaveLength(1); + }); + + test("per-project override: recall scope override saves and persists across reload", async ({ page }) => { + test.skip(!ready, "Hindsight embedded-dashboard/marketplace stack not served in this environment"); + test.skip(!(await overrideContractReady()), "config route does not expose the per-project override contract here"); + await putHindsightConfig({ externalUrl: stub.url, bank: "hermes" }); + + await openWithSession(page); + let row = await openMarketRow(page); + + await row.locator('[data-testid="market-hindsight-configure"]').click(); + const form = row.locator('[data-testid="market-hindsight-config-form"]'); + await expect(form.locator('[data-testid="market-hindsight-form-mode"]')).toBeVisible({ timeout: 15_000 }); + const override = row.locator('[data-testid="market-hindsight-override"]'); + await expect(override, "the per-project override section is shown").toBeVisible({ timeout: 15_000 }); + + await override.locator('[data-testid="market-hindsight-override-recallscope"]').selectOption("all"); + await override.locator('[data-testid="market-hindsight-override-save"]').click(); + await expect(override.locator('[data-testid="market-hindsight-override-result"]'), "the override save reports a result").toContainText("Saved", { timeout: 20_000 }); + + await expect(row.locator('[data-testid="market-hindsight-override-active"]'), "the override badge appears").toBeVisible({ timeout: 20_000 }); + await expect(form.locator('[data-testid="market-hindsight-form-recallscope"]'), "the global recall-scope field shows the global value, not the project override").toHaveValue("project", { timeout: 15_000 }); + + await page.reload(); + row = await openMarketRow(page); + await expect(row.locator('[data-testid="market-hindsight-override-active"]'), "the override badge persists across reload").toBeVisible({ timeout: 20_000 }); + await row.locator('[data-testid="market-hindsight-configure"]').click(); + const override2 = row.locator('[data-testid="market-hindsight-override"]'); + await expect(override2.locator('[data-testid="market-hindsight-override-recallscope"]'), "the saved override value persists").toHaveValue("all", { timeout: 15_000 }); + + await override2.locator('[data-testid="market-hindsight-override-recallscope"]').selectOption(""); + await override2.locator('[data-testid="market-hindsight-override-save"]').click(); + await expect(override2.locator('[data-testid="market-hindsight-override-result"]')).toContainText("Saved", { timeout: 20_000 }); + }); + + test("a stored object lastError renders its message (never [object Object])", async ({ page }) => { + test.skip(!ready, "Hindsight embedded-dashboard/marketplace stack not served in this environment"); + const { getPackStore } = await import("../../../dist/server/extension-host/pack-store.js"); + try { + await putHindsightConfig({ externalUrl: stub.url, bank: "hermes" }); + await getPackStore().put(PACK, "last-error", { message: "Hindsight HTTP 503 for POST /recall", ts: Date.now() }); + + await openWithSession(page); + const row = await openMarketRow(page); + + const lastErr = row.locator('[data-testid="market-hindsight-last-error"]'); + await expect(lastErr, "the object lastError renders its message").toBeVisible({ timeout: 20_000 }); + await expect(lastErr).toContainText("Hindsight HTTP 503 for POST /recall"); + await expect(lastErr, "an object lastError must never stringify to [object Object]").not.toContainText("[object Object]"); + } finally { + await getPackStore().put(PACK, "last-error", null); + } + }); +}); diff --git a/tests/e2e/ui/hindsight-pack.spec.ts b/tests/e2e/ui/hindsight-pack.spec.ts new file mode 100644 index 000000000..efc285fbb --- /dev/null +++ b/tests/e2e/ui/hindsight-pack.spec.ts @@ -0,0 +1,380 @@ +/** + * Browser E2E — Hindsight EMBEDDED DASHBOARD ENTRY (design "Hindsight surfaces & + * embedded dashboard" — Dashboard panel behavior + Configuration-home rule). + * + * This goal RE-DEFINES the Hindsight extension entry: the session-menu item and the + * `#/ext/` deep link no longer open the native config/status panel — they + * open the new `hindsight.dashboard` panel, an embedded sandboxed iframe of the + * configured human dashboard `uiUrl`. Configuration moved to the Marketplace + * (covered by hindsight-marketplace.spec.ts + hindsight-wizard.spec.ts). This spec + * pins the USE surface: + * + * 1. EMBED — with a distinct `externalUrl` (data plane) + `uiUrl` (human + * dashboard) configured, launching the session-menu entry mounts + * `hindsight-dashboard-frame` whose `src` === the configured `uiUrl`, and NO + * `hindsight-config-card` is rendered (the entry is not a config surface). + * 2. DEEP LINK — a full reload + `#/ext/` re-opens the same embedded + * dashboard iframe (no config card). + * 3. EXTERNAL FALLBACK — a secondary `hindsight-dashboard-open-external` anchor + * points at the same `uiUrl` (target=_blank, rel=noopener) for the case where + * a remote/secured dashboard refuses framing. + * 4. EMPTY STATE — when `uiUrl` is unset the entry does NOT dead-end: it renders + * `hindsight-dashboard-empty` (with a Marketplace CTA) and NO config card / no + * iframe. + * 5. BLOCKED / UNREACHABLE — using the deterministic `window.__bobbitHindsight + * IframeTimeoutMs` test hook, an iframe that never fires `load` surfaces + * `hindsight-dashboard-embed-warning` while keeping the external fallback link. + * + * #820 invariants (no-clobber config write, no-auto-start Docker, dormancy) are + * preserved at the route level by tests/e2e/hindsight-config-write.spec.ts and at the + * Marketplace surface by hindsight-marketplace.spec.ts / hindsight-wizard.spec.ts — + * this spec deliberately stops exercising config writes through the entry, because + * the entry is no longer a configuration surface. + * + * SKIP-GUARD: a static STACK_READY (the new dashboard panel bundle + descriptor + + * the stub must exist) gates the whole suite, plus a per-test runtime check that the + * built-in band actually SERVES the `hindsight.dashboard` contribution in this + * environment (dist not rebuilt / parallel coder branches not merged ⇒ skip). This + * keeps the suite green-by-skip until the embedded-dashboard implementation lands. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test, expect } from "../gateway-harness.js"; +import type { Page } from "@playwright/test"; +import { apiFetch, base, readE2ETokenAsync, waitForSessionStatus } from "../e2e-setup.js"; +import { openApp, createSessionViaUI } from "./ui-helpers.js"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); + +const PACK = "hindsight"; +const DASHBOARD_PANEL_ID = "hindsight.dashboard"; +const PACK_SRC = path.resolve(__dirname, "..", "..", "..", "market-packs", PACK); +const STUB_PATH = path.resolve(__dirname, "..", "hindsight-stub.mjs"); +const CONFIG_KEY = "provider-config:memory"; + +// Distinct API/data-plane URL (dialed by Bobbit) vs human dashboard UI URL +// (display/iframe-only, NEVER dialed by the client). The whole point of this goal is +// that the embedded dashboard loads the UI URL verbatim — never a value fabricated +// from the API URL. +const EX_UI_URL = "http://127.0.0.1:19177/banks/hermes?view=data"; +const UNREACHABLE_UI_URL = "http://127.0.0.1:1/banks/hermes?view=data&__bobbit_hindsight_timeout_ms=50&__bobbit_hindsight_force_timeout=1"; + +// Static skip-guard: the NEW embedded-dashboard panel bundle + descriptor must exist +// before this suite means anything. On this (test-only) branch the parallel coder +// branches are not merged, so the suite skips entirely until the stack lands. +const STACK_READY = + fs.existsSync(path.join(PACK_SRC, "lib", "HindsightDashboardPanel.js")) && + fs.existsSync(path.join(PACK_SRC, "panels", "hindsight-dashboard.yaml")) && + fs.existsSync(STUB_PATH); + +const describe = STACK_READY ? test.describe : test.describe.skip; + +interface HindsightStub { + url: string; + setHealthy(ok: boolean): void; + close(): Promise; +} +async function startStub(): Promise { + const mod = await import(STUB_PATH as string); + const start = mod.startHindsightStub ?? mod.default; + return start({ port: 0 }) as Promise; +} + +interface PackContributionsMeta { + packId: string; + panels?: { id: string; title?: string }[]; + entrypoints?: Array<{ id: string; kind: string; routeId?: string; listName: string }>; + routeNames?: string[]; +} +async function listContributions(): Promise { + const res = await apiFetch("/api/ext/contributions"); + if (!res.ok) return []; + return ((await res.json()).packs ?? []) as PackContributionsMeta[]; +} + +interface DashboardContribution { + paletteEntrypointId: string; + routeId: string; +} + +/** Runtime readiness: the built-in band must serve the NEW `hindsight.dashboard` + * panel, the session-menu + route entrypoints (now retargeted to it), and the + * config/status routes, AND the panel module must be fetchable. Returns the + * discovered session-menu entrypoint id + deep-link routeId, or null when the + * embedded-dashboard contribution is unavailable here (→ skip). */ +async function resolveDashboardContribution(): Promise { + const meta = (await listContributions()).find((p) => p.packId === PACK); + if (!meta) return null; + if (!meta.panels?.some((p) => p.id === DASHBOARD_PANEL_ID)) return null; + const palette = meta.entrypoints?.find((e) => e.kind === "session-menu"); + const route = meta.entrypoints?.find((e) => e.kind === "route" && !!e.routeId); + if (!palette || !route?.routeId) return null; + for (const r of ["config", "status"]) { + if (!meta.routeNames?.includes(r)) return null; + } + const panelRes = await apiFetch(`/api/ext/packs/${PACK}/panels/${encodeURIComponent(DASHBOARD_PANEL_ID)}`); + if (!panelRes.ok) return null; + return { paletteEntrypointId: palette.id, routeId: route.routeId }; +} + +/** The compound launcher key `runLauncherEntrypoint` dispatches on + * (`packId NUL entrypointId`) — the SAME key the session-menu uses per item. */ +function launcherKey(entrypointId: string): string { + return `${PACK}\u0000${entrypointId}`; +} + +async function reconcile(page: Page): Promise { + await page.evaluate(() => (window as any).__bobbitReconcilePackRenderers?.()).catch(() => { /* race */ }); +} + +const frame = (page: Page) => page.locator('[data-testid="hindsight-dashboard-frame"]').first(); +const emptyState = (page: Page) => page.locator('[data-testid="hindsight-dashboard-empty"]').first(); +const embedWarning = (page: Page) => page.locator('[data-testid="hindsight-dashboard-embed-warning"]').first(); +const externalLink = (page: Page) => page.locator('[data-testid="hindsight-dashboard-open-external"]').first(); +const configCard = (page: Page) => page.locator('[data-testid="hindsight-config-card"]'); + +/** Force-enable the default-disabled built-in Hindsight pack at server scope so its + * dashboard panel + entrypoints + routes are SERVED regardless of worker ordering. */ +async function forceEnableHindsight(): Promise { + await apiFetch("/api/marketplace/pack-activation", { + method: "PUT", + body: JSON.stringify({ scope: "server", packName: PACK, disabled: {} }), + }).catch(() => { /* best-effort */ }); +} + +/** Reset the persisted Hindsight config in the shared in-process pack store. */ +async function resetHindsightConfig(): Promise { + try { + const { getPackStore } = await import("../../../dist/server/extension-host/pack-store.js"); + await getPackStore().put(PACK, CONFIG_KEY, {}); + } catch { /* best-effort */ } +} + +/** Seed the persisted Hindsight config — the dashboard panel reads `uiUrl` from the + * `config`/`status` route on mount, so the embedded iframe `src` derives from this. */ +async function seedHindsightConfig(overrides: Record): Promise { + const { getPackStore } = await import("../../../dist/server/extension-host/pack-store.js"); + await getPackStore().put(PACK, CONFIG_KEY, overrides); +} + +/** Return the built-in Hindsight pack to its DEFAULT-DISABLED baseline so the enabled + * state cannot LEAK to sibling spec files sharing the worker's in-process gateway. */ +async function resetHindsightActivation(): Promise { + try { + const res = await apiFetch(`/api/marketplace/pack-activation?scope=server&packName=${PACK}`); + if (!res.ok) return; + const cat = ((await res.json()).catalogue ?? {}) as Record; + const arr = (k: string): string[] => + Array.isArray(cat[k]) + ? (cat[k] as Array<{ listName?: string } | string>).map((e) => (typeof e === "string" ? e : e.listName ?? "")).filter(Boolean) + : []; + const disabled = { + roles: arr("roles"), tools: arr("tools"), skills: arr("skills"), entrypoints: arr("entrypoints"), + providers: arr("providers"), hooks: arr("hooks"), mcp: arr("mcp"), piExtensions: arr("piExtensions"), + runtimes: arr("runtimes"), workflows: arr("workflows"), + }; + await apiFetch("/api/marketplace/pack-activation", { + method: "PUT", + body: JSON.stringify({ scope: "server", packName: PACK, disabled }), + }); + } catch { /* best-effort */ } +} + +/** Clear the per-PAGE dashboard panel state cache (the module-closure Map the panel + * keys by sessionId). The panel kicks its READ-only `config`+`status` loads exactly + * ONCE per session (guarded by `mountKicked`) and is a pure projection thereafter — + * so if BOTH route reads transiently fail/time-out at mount (rare, only under heavy + * CPU contention with sibling workers), the panel latches an empty/error projection + * and never re-reads. Clearing the cache before re-opening forces a FRESH mount that + * re-derives the surface from the persisted (server-side) config — this is exactly + * the state a real page reload produces, so it does not weaken the persistence claim. */ +async function clearDashboardPanelCache(page: Page): Promise { + await page.evaluate(() => { + try { (globalThis as any).__bobbitHindsightDashboardState?.clear?.(); } catch { /* not mounted yet */ } + }).catch(() => { /* page navigating */ }); +} + +/** Deterministically wait until the dashboard panel SETTLES on the expected surface + * (`frame` when a uiUrl is configured, `empty` otherwise), re-driving `open()` each + * attempt. While still stuck it also clears the per-page panel cache so a transient + * mount-time route read cannot LATCH a wrong/empty projection that never re-reads. + * Once the surface is present it is left untouched (no further clears/re-opens), so + * follow-on state — e.g. the deterministic embed-warning timeout — is never disturbed. */ +async function waitForDashboardSurface( + page: Page, + want: "frame" | "empty", + open: () => Promise, + timeout = 20_000, +): Promise { + const target = want === "frame" ? frame(page) : emptyState(page); + await open(); + await reconcile(page); + await expect.poll(async () => { + const n = await target.count(); + if (n > 0) return n; // settled — do not disturb the live surface + await reconcile(page); + await clearDashboardPanelCache(page); + await open(); + await reconcile(page); + return target.count(); + }, { timeout }).toBeGreaterThan(0); +} + +/** Open the app + select a fresh session, then mount the Hindsight dashboard panel via + * the session-menu launcher (the SAME chain a menu click drives). Deterministically + * waits for the EXPECTED surface (frame/empty) so a transient mount-time read can + * never latch the wrong projection. Returns the session id so the caller can deep-link. */ +async function mountDashboard(page: Page, contribution: DashboardContribution, want: "frame" | "empty" = "frame"): Promise { + await openApp(page); + const sid = await createSessionViaUI(page); + expect(sid, "a session must be selected").toBeTruthy(); + await waitForSessionStatus(sid, "idle").catch(() => { /* best-effort */ }); + await reconcile(page); + const open = () => page.evaluate((key) => (window as any).__bobbitRunPackLauncher?.(key), launcherKey(contribution.paletteEntrypointId)).catch(() => { /* race */ }); + await waitForDashboardSurface(page, want, open); + return sid; +} + +test.describe.configure({ mode: "serial" }); + +describe("Hindsight pack — embedded dashboard entry (use surface)", () => { + let stub: HindsightStub; + + test.beforeAll(async () => { + await forceEnableHindsight(); + await resetHindsightConfig(); + stub = await startStub(); + }); + + test.afterAll(async () => { + await resetHindsightConfig(); + await resetHindsightActivation(); + if (stub) await stub.close().catch(() => { /* ignore */ }); + }); + + test.beforeEach(async () => { + await resetHindsightConfig(); + }); + + test("session-menu entry embeds the dashboard iframe (src = uiUrl), shows no config card, and re-opens via #/ext deep link", async ({ page }) => { + const contribution = await resolveDashboardContribution(); + test.skip(!contribution, "Hindsight embedded-dashboard contribution is not served in this environment"); + const { routeId } = contribution!; + + // Configure a DISTINCT data-plane URL + human dashboard UI URL out of band + // (Marketplace is the config home; here we only exercise the use surface). + await seedHindsightConfig({ externalUrl: stub.url, bank: "hermes", uiUrl: EX_UI_URL }); + expect(EX_UI_URL).not.toBe(stub.url); + + // Keep the embed warning out of the way for the happy path: a generous iframe + // load-timeout means the deterministic warning never fires within the test. + await page.addInitScript(() => { (window as any).__bobbitHindsightIframeTimeoutMs = 60_000; }); + + const sid = await mountDashboard(page, contribution!); + + // The embedded iframe mounts with src === the configured UI URL — verbatim. + await expect(frame(page), "the entry embeds the dashboard iframe").toBeVisible({ timeout: 15_000 }); + await expect(frame(page), "the iframe loads the configured UI URL verbatim (never the API URL)").toHaveAttribute("src", EX_UI_URL, { timeout: 15_000 }); + + // The entry is NOT a configuration surface: no config card is rendered. + await expect(configCard(page), "the entry must not open a config card").toHaveCount(0); + + // PERSISTENCE: a full reload + the bare deep link re-opens the SAME embedded + // dashboard (config persisted server-side; still not a config surface). + const token = await readE2ETokenAsync(); + await page.goto(`${base()}/?token=${encodeURIComponent(token)}#/session/${sid}`); + await expect(page.locator("textarea").first()).toBeVisible({ timeout: 20_000 }); + await reconcile(page); + // Drive the bare `#/ext/` deep link. Toggle via the session route so + // re-opening is observable even when the hash is already the ext route, then + // wait deterministically for the embedded frame to settle (recovering from a + // transient mount-time read latch — see waitForDashboardSurface). + const openDeepLink = async () => { + await page.evaluate((s) => { window.location.hash = `#/session/${s}`; }, sid).catch(() => { /* race */ }); + await page.evaluate((h) => { window.location.hash = h; }, `#/ext/${routeId}`).catch(() => { /* race */ }); + }; + await waitForDashboardSurface(page, "frame", openDeepLink); + await expect(frame(page), "the deep link re-opens the embedded dashboard").toBeVisible({ timeout: 15_000 }); + await expect(frame(page), "the re-opened iframe keeps the configured UI URL").toHaveAttribute("src", EX_UI_URL, { timeout: 15_000 }); + await expect(configCard(page), "the deep link must not open a config card").toHaveCount(0); + }); + + test("the embedded dashboard iframe fills the panel height (not collapsed to the ~320px min-height floor)", async ({ page }) => { + const contribution = await resolveDashboardContribution(); + test.skip(!contribution, "Hindsight embedded-dashboard contribution is not served in this environment"); + + // A standard desktop viewport. The bug: the iframe collapsed to its wrap's + // `min-height` floor (~320px) instead of filling the panel, because the + // height chain to the `height:100%` iframe was not DEFINITE end-to-end. + await page.setViewportSize({ width: 1280, height: 800 }); + await seedHindsightConfig({ externalUrl: stub.url, bank: "hermes", uiUrl: EX_UI_URL }); + await page.addInitScript(() => { (window as any).__bobbitHindsightIframeTimeoutMs = 60_000; }); + await mountDashboard(page, contribution!); + + const f = frame(page); + await expect(f).toBeVisible({ timeout: 15_000 }); + + // Measure the ACTUAL rendered height — not mere visibility. The frame must be + // TALL (a high fraction of the panel), proving the definite height chain. + const box = await f.boundingBox(); + expect(box, "the iframe has a bounding box").not.toBeNull(); + expect(box!.height, `iframe collapsed to ${Math.round(box!.height)}px — must fill the panel`).toBeGreaterThan(500); + + // Cross-check it actually tracks the panel container, not a fixed pixel value. + const panelBox = await page.locator('[data-testid="pack-panel-root"]').first().boundingBox(); + expect(panelBox, "the pack panel root has a bounding box").not.toBeNull(); + expect(box!.height, "the iframe fills most of the panel height").toBeGreaterThan(panelBox!.height * 0.7); + }); + + test("a secondary external fallback link points at the same uiUrl (target=_blank, rel=noopener)", async ({ page }) => { + const contribution = await resolveDashboardContribution(); + test.skip(!contribution, "Hindsight embedded-dashboard contribution is not served in this environment"); + + await seedHindsightConfig({ externalUrl: stub.url, bank: "hermes", uiUrl: EX_UI_URL }); + await page.addInitScript(() => { (window as any).__bobbitHindsightIframeTimeoutMs = 60_000; }); + await mountDashboard(page, contribution!); + + await expect(frame(page)).toBeVisible({ timeout: 15_000 }); + const link = externalLink(page); + await expect(link, "an external open-in-browser fallback is offered").toBeVisible({ timeout: 15_000 }); + await expect(link, "the fallback opens the UI URL verbatim").toHaveAttribute("href", EX_UI_URL); + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", /noopener/); + }); + + test("unset uiUrl renders a helpful empty state (Marketplace CTA) — not a dead end, not a config card, no iframe", async ({ page }) => { + const contribution = await resolveDashboardContribution(); + test.skip(!contribution, "Hindsight embedded-dashboard contribution is not served in this environment"); + + // Data-plane URL is configured but the human dashboard UI URL is NOT. + await seedHindsightConfig({ externalUrl: stub.url, bank: "hermes" }); + await mountDashboard(page, contribution!, "empty"); + + await expect(emptyState(page), "an unset UI URL renders the empty state").toBeVisible({ timeout: 15_000 }); + await expect(frame(page), "no iframe is rendered without a UI URL").toHaveCount(0); + await expect(configCard(page), "the empty state must not be a config form").toHaveCount(0); + // The empty state must point the user at the Marketplace (the config home) — a + // CTA/link to #/market, not a dead end. + await expect(emptyState(page), "the empty state offers a Marketplace configuration CTA").toContainText(/market|configure/i); + }); + + test("blocked/unreachable iframe surfaces the embed warning (deterministic timeout hook) while keeping the external fallback", async ({ page }) => { + const contribution = await resolveDashboardContribution(); + test.skip(!contribution, "Hindsight embedded-dashboard contribution is not served in this environment"); + + // A reachable-shaped but framing-refused / unreachable UI URL. The parent cannot + // detect XFO/CSP refusal, so the panel uses a load-timeout. Drive it deterministically. + await seedHindsightConfig({ externalUrl: stub.url, bank: "hermes", uiUrl: UNREACHABLE_UI_URL }); + await mountDashboard(page, contribution!); + + // The frame still mounts (src is set) but the load never completes → the warning. + await expect(embedWarning(page), "an iframe that never loads surfaces the embed warning").toBeVisible({ timeout: 15_000 }); + // The external fallback stays available so the user is never stranded. + const link = externalLink(page); + await expect(link, "the external fallback remains available when embedding is blocked").toBeVisible({ timeout: 15_000 }); + await expect(link).toHaveAttribute("href", UNREACHABLE_UI_URL); + // Still never a config surface. + await expect(configCard(page)).toHaveCount(0); + }); +}); diff --git a/tests/e2e/ui/hindsight-wizard.spec.ts b/tests/e2e/ui/hindsight-wizard.spec.ts new file mode 100644 index 000000000..854907b67 --- /dev/null +++ b/tests/e2e/ui/hindsight-wizard.spec.ts @@ -0,0 +1,348 @@ +/** + * Browser E2E — Hindsight GUIDED SETUP WIZARD (Marketplace), redefined by the + * "Hindsight surfaces & embedded dashboard" goal. The Marketplace is the + * configuration home; the wizard's actions must be mode-specific and actually work: + * + * 1. MODE SELECTABLE — all three mode cards are clickable `