From af00429abe05a9a94f6a12296a8dbe878817a90f Mon Sep 17 00:00:00 2001 From: scanner Date: Fri, 16 Oct 2026 04:57:45 -0400 Subject: [PATCH] feat(dashboard): expose knowledge.* settings in the Settings dialog (parity batch 8) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: scanner Co-authored-by: clubanderson <407614+clubanderson@users.noreply.github.com> --- changelog.d/added-settings-parity-batch-8.md | 1 + dashboard/openapi.json | 456 ++++++++++++++++++ src/docs/api-reference.md | 146 +++--- src/docs/settings.md | 4 + src/pkg/config/config.go | 20 +- src/pkg/config/knowledge_config.go | 93 ++++ src/pkg/dashboard/api.go | 2 + .../api_config_knowledge_pipeline.go | 338 +++++++++++++ .../knowledge_settings_parity_test.go | 293 +++++++++++ .../owner_only_handlers_deny_test.go | 2 + .../dashboard/settings_parity_allowlist.txt | 26 - src/pkg/dashboard/static/index.html | 217 ++++++++- 12 files changed, 1488 insertions(+), 110 deletions(-) create mode 100644 changelog.d/added-settings-parity-batch-8.md create mode 100644 src/pkg/dashboard/api_config_knowledge_pipeline.go create mode 100644 src/pkg/dashboard/knowledge_settings_parity_test.go diff --git a/changelog.d/added-settings-parity-batch-8.md b/changelog.d/added-settings-parity-batch-8.md new file mode 100644 index 0000000000..118b457a21 --- /dev/null +++ b/changelog.d/added-settings-parity-batch-8.md @@ -0,0 +1 @@ +- Expose the knowledge pipeline settings in the dashboard Settings dialog: a new Knowledge pipeline block on the Knowledge tab edits the bead synthesizer (on/off applies at once; schedule, target layer, minimum confidence, per-cycle fact and GitHub API caps, bead retention), scheduled curator promotion, repository code maps and the kick primer caps through the owner-only `PUT /api/config/knowledge/pipeline`, shows the engine, vault paths and primer merge strategy read-only, and labels the public read-only MCP switch with its YAML key (#11854). diff --git a/dashboard/openapi.json b/dashboard/openapi.json index 309ce48713..d9371107fb 100644 --- a/dashboard/openapi.json +++ b/dashboard/openapi.json @@ -12568,6 +12568,462 @@ } } }, + "/api/config/knowledge/pipeline": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "Knowledge pipeline settings", + "description": "Returns the scalar knowledge.bead_synthesizer, knowledge.curator, knowledge.code_maps and knowledge.primer settings, plus the read-only engine, vault paths and primer merge strategy. Requires owner role.", + "responses": { + "200": { + "description": "Knowledge pipeline settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "engine": { + "type": "string", + "description": "Read-only: knowledge.engine" + }, + "bead_synthesizer": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Starts or stops the bead synthesizer at once" + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty restores hourly. Requires restart." + }, + "min_confidence": { + "type": "number", + "description": "0-1; 0 restores the default (0.5). Requires restart." + }, + "target_layer": { + "type": "string", + "description": "personal, project, org or community; empty restores project. Requires restart." + }, + "max_facts_per_cycle": { + "type": "integer", + "description": "0 restores the default (20). Requires restart." + }, + "max_api_calls_per_cycle": { + "type": "integer", + "description": "0 restores the default (40). Requires restart." + }, + "retention_policy": { + "type": "object", + "properties": { + "max_beads": { + "type": "integer", + "description": "0 uses the lifecycle default (5000). Requires restart." + }, + "archive_after_synth_days": { + "type": "integer", + "description": "0 uses the lifecycle default (7). Requires restart." + }, + "high_priority_retain_days": { + "type": "integer", + "description": "0 uses the lifecycle default (30). Requires restart." + }, + "preserve_with_deps": { + "type": "boolean", + "description": "Never archive a bead with open dependents. Requires restart." + } + } + }, + "vault_path": { + "type": "string", + "description": "Read-only: the directory the synthesizer writes to" + } + } + }, + "curator": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Scheduled auto-promotion opt-in. Requires restart." + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty uses daily once enabled. Requires restart." + }, + "auto_promote_threshold": { + "type": "number", + "description": "0-1; 0 restores the default (0.9). Requires restart." + }, + "promote_from": { + "type": "string", + "description": "Source layer; empty = project. Must differ from promote_to. Requires restart." + }, + "promote_to": { + "type": "string", + "description": "Target layer; empty = org. Requires restart." + } + } + }, + "code_maps": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Code map generation opt-in. Requires restart." + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty = hourly. Requires restart." + }, + "layer": { + "type": "string", + "description": "Layer recorded on each map; empty = project. Requires restart." + }, + "max_bytes": { + "type": "integer", + "description": "Per-map size cap; 0 uses the generator default (16384). Requires restart." + }, + "vault_path": { + "type": "string", + "description": "Read-only: the directory maps are written to; empty = /data/vaults/code-maps" + } + } + }, + "primer": { + "type": "object", + "properties": { + "max_facts": { + "type": "integer", + "description": "0 restores the default (25). Requires restart." + }, + "max_bytes": { + "type": "integer", + "description": "0 restores the default (4096). Requires restart." + }, + "merge_strategy": { + "type": "string", + "description": "Read-only: precedence is the only implemented strategy" + } + } + }, + "schedules": { + "type": "array", + "items": { + "type": "string" + } + }, + "layers": { + "type": "array", + "items": { + "type": "string" + } + }, + "defaults": { + "type": "object", + "description": "Defaults a cleared field restores" + } + } + } + } + } + }, + "403": { + "description": "Requires owner role" + } + } + }, + "put": { + "tags": [ + "Knowledge" + ], + "summary": "Update knowledge pipeline settings", + "description": "Updates the scalar bead synthesizer, curator, code map and primer settings. Fields are pointer-typed; absent fields are left unchanged and 0 or empty restores the default. Validated with config.ValidateKnowledgePipeline before anything is applied, then persisted and audited as config_knowledge_pipeline. bead_synthesizer.enabled starts or stops the synthesizer at once; every other field applies after a restart. Requires owner role.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bead_synthesizer": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Starts or stops the bead synthesizer at once" + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty restores hourly. Requires restart." + }, + "min_confidence": { + "type": "number", + "description": "0-1; 0 restores the default (0.5). Requires restart." + }, + "target_layer": { + "type": "string", + "description": "personal, project, org or community; empty restores project. Requires restart." + }, + "max_facts_per_cycle": { + "type": "integer", + "description": "0 restores the default (20). Requires restart." + }, + "max_api_calls_per_cycle": { + "type": "integer", + "description": "0 restores the default (40). Requires restart." + }, + "retention_policy": { + "type": "object", + "properties": { + "max_beads": { + "type": "integer", + "description": "0 uses the lifecycle default (5000). Requires restart." + }, + "archive_after_synth_days": { + "type": "integer", + "description": "0 uses the lifecycle default (7). Requires restart." + }, + "high_priority_retain_days": { + "type": "integer", + "description": "0 uses the lifecycle default (30). Requires restart." + }, + "preserve_with_deps": { + "type": "boolean", + "description": "Never archive a bead with open dependents. Requires restart." + } + } + } + } + }, + "curator": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Scheduled auto-promotion opt-in. Requires restart." + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty uses daily once enabled. Requires restart." + }, + "auto_promote_threshold": { + "type": "number", + "description": "0-1; 0 restores the default (0.9). Requires restart." + }, + "promote_from": { + "type": "string", + "description": "Source layer; empty = project. Must differ from promote_to. Requires restart." + }, + "promote_to": { + "type": "string", + "description": "Target layer; empty = org. Requires restart." + } + } + }, + "code_maps": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Code map generation opt-in. Requires restart." + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty = hourly. Requires restart." + }, + "layer": { + "type": "string", + "description": "Layer recorded on each map; empty = project. Requires restart." + }, + "max_bytes": { + "type": "integer", + "description": "Per-map size cap; 0 uses the generator default (16384). Requires restart." + } + } + }, + "primer": { + "type": "object", + "properties": { + "max_facts": { + "type": "integer", + "description": "0 restores the default (25). Requires restart." + }, + "max_bytes": { + "type": "integer", + "description": "0 restores the default (4096). Requires restart." + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated knowledge pipeline settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "engine": { + "type": "string", + "description": "Read-only: knowledge.engine" + }, + "bead_synthesizer": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Starts or stops the bead synthesizer at once" + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty restores hourly. Requires restart." + }, + "min_confidence": { + "type": "number", + "description": "0-1; 0 restores the default (0.5). Requires restart." + }, + "target_layer": { + "type": "string", + "description": "personal, project, org or community; empty restores project. Requires restart." + }, + "max_facts_per_cycle": { + "type": "integer", + "description": "0 restores the default (20). Requires restart." + }, + "max_api_calls_per_cycle": { + "type": "integer", + "description": "0 restores the default (40). Requires restart." + }, + "retention_policy": { + "type": "object", + "properties": { + "max_beads": { + "type": "integer", + "description": "0 uses the lifecycle default (5000). Requires restart." + }, + "archive_after_synth_days": { + "type": "integer", + "description": "0 uses the lifecycle default (7). Requires restart." + }, + "high_priority_retain_days": { + "type": "integer", + "description": "0 uses the lifecycle default (30). Requires restart." + }, + "preserve_with_deps": { + "type": "boolean", + "description": "Never archive a bead with open dependents. Requires restart." + } + } + }, + "vault_path": { + "type": "string", + "description": "Read-only: the directory the synthesizer writes to" + } + } + }, + "curator": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Scheduled auto-promotion opt-in. Requires restart." + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty uses daily once enabled. Requires restart." + }, + "auto_promote_threshold": { + "type": "number", + "description": "0-1; 0 restores the default (0.9). Requires restart." + }, + "promote_from": { + "type": "string", + "description": "Source layer; empty = project. Must differ from promote_to. Requires restart." + }, + "promote_to": { + "type": "string", + "description": "Target layer; empty = org. Requires restart." + } + } + }, + "code_maps": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Code map generation opt-in. Requires restart." + }, + "schedule": { + "type": "string", + "description": "hourly or daily; empty = hourly. Requires restart." + }, + "layer": { + "type": "string", + "description": "Layer recorded on each map; empty = project. Requires restart." + }, + "max_bytes": { + "type": "integer", + "description": "Per-map size cap; 0 uses the generator default (16384). Requires restart." + }, + "vault_path": { + "type": "string", + "description": "Read-only: the directory maps are written to; empty = /data/vaults/code-maps" + } + } + }, + "primer": { + "type": "object", + "properties": { + "max_facts": { + "type": "integer", + "description": "0 restores the default (25). Requires restart." + }, + "max_bytes": { + "type": "integer", + "description": "0 restores the default (4096). Requires restart." + }, + "merge_strategy": { + "type": "string", + "description": "Read-only: precedence is the only implemented strategy" + } + } + }, + "schedules": { + "type": "array", + "items": { + "type": "string" + } + }, + "layers": { + "type": "array", + "items": { + "type": "string" + } + }, + "defaults": { + "type": "object", + "description": "Defaults a cleared field restores" + } + } + } + } + } + }, + "400": { + "description": "Invalid schedule, layer, confidence or negative count" + }, + "403": { + "description": "Requires owner role" + } + } + } + }, "/api/config/knowledge/connectors/{name}/sync": { "post": { "tags": [ diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md index fc4043514b..5f687d1d18 100644 --- a/src/docs/api-reference.md +++ b/src/docs/api-reference.md @@ -176,7 +176,7 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | `GET` | `/api/compliance/export` | Owner only | Evidence download for `?since=`/`?until=`: `kind=controls` (`json`/`md`), `posture` (`json`/`csv`), `audit` (`json`), `config` (redacted effective config + sha256, `json`), `attestations` (`json`/`csv`) or `bundle` (`json`); audited as `compliance_export` ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:335` | | `GET` | `/api/compliance/attestations` | Owner only | Owner attestations, newest first (`?framework=` filters) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:336` | | `POST` | `/api/compliance/attestations` | Owner only | Record `{"framework", "reviewed_on", "note"}` for the signed-in owner; audited as `compliance_attestation`; `201` with the attestation, `400` for an unknown framework, a bad or future date, or a note over 2000 characters ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:337` | -| `PUT` | `/api/config/governor/compliance` | Owner only | Settings → Compliance framework picker: `{"frameworks": [...]}` sets `compliance.frameworks` (empty list deselects all, absent key leaves it unchanged; unknown/duplicate ids are a `400` before any change); audited as `config_compliance`; returns the re-evaluated `compliance.Report` ([#11080](https://github.com/hivecommons/hive/issues/11080)) | `pkg/dashboard/api.go:499` | +| `PUT` | `/api/config/governor/compliance` | Owner only | Settings → Compliance framework picker: `{"frameworks": [...]}` sets `compliance.frameworks` (empty list deselects all, absent key leaves it unchanged; unknown/duplicate ids are a `400` before any change); audited as `config_compliance`; returns the re-evaluated `compliance.Report` ([#11080](https://github.com/hivecommons/hive/issues/11080)) | `pkg/dashboard/api.go:501` | | `GET` | `/api/config/governor/threshold-scaling` | Owner only | Governor Threshold Scaling Get | `pkg/dashboard/api.go:203` | | `PUT` | `/api/config/governor/threshold-scaling` | Owner only | Governor Threshold Scaling Set | `pkg/dashboard/api.go:204` | @@ -185,8 +185,8 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | `PUT` | `/api/config/governor/watchdog` | Owner only | Governor Watchdog | `pkg/dashboard/api.go:213` | | `GET` | `/api/config/escalation` | Owner only | Escalation Config Get | `pkg/dashboard/api.go:216` | | `PUT` | `/api/config/escalation` | Owner only | Escalation Config Set | `pkg/dashboard/api.go:217` | -| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:495` | -| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:496` | +| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:497` | +| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:498` | | `GET` | `/api/config/review` | Dashboard auth/session | Review Config Get | `pkg/dashboard/api.go:220` | | `PUT` | `/api/config/review` | Owner only | Review Config Set | `pkg/dashboard/api.go:223` | | `PUT` | `/api/config/governor/features` | Owner only | Governor Features | `pkg/dashboard/api.go:229` | @@ -230,15 +230,15 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | `GET` | `/api/model-advisor` | Dashboard auth/session | Model Advisor | `pkg/dashboard/api.go:162` | | `GET` | `/api/governor/pr-models` | Dashboard auth/session | Agent-authored PR distribution by normalized attribution model/backend for `window=7d`, `30d`, or `all`, including per-model rework stats and the top 10 most-reworked PRs | `pkg/dashboard/api.go:163` | | `GET` | `/api/reviewer/accuracy` | Dashboard auth/session | Reviewer verdict calibration for the configured window: false-approve/false-block rates and confidence buckets per perspective and reviewer model | `pkg/dashboard/api.go:222` | -| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; unreviewed items carry `pending_since` and `redispatch_count` from the review dispatch state; paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:474` | -| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/close` | Owner only | Close a PR in the supersession grace window now: posts the close comment, adds `hive/superseded`, closes it | `pkg/dashboard/api.go:477` | -| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/keep-open` | Owner only | Keep a superseded PR open: adds `hive/keep-open`, which the supersession sweep never auto-closes | `pkg/dashboard/api.go:478` | -| `GET` | `/api/review/pipeline` | Dashboard auth/session | Review pipeline: one card per review-queue PR placed in its stage (`unreviewed`, `reviewing`, `changes_requested`, `fixing`, `human_hold`, `approved`, `merged`, `abandoned`) with `since`, `reviewers`, P0–P3 `severity` counts, `loop_count`/`loop_cap`, `next_action` and `reasons`; filtered by `repo` and `stage`, paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:482` | +| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; unreviewed items carry `pending_since` and `redispatch_count` from the review dispatch state; paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:476` | +| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/close` | Owner only | Close a PR in the supersession grace window now: posts the close comment, adds `hive/superseded`, closes it | `pkg/dashboard/api.go:479` | +| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/keep-open` | Owner only | Keep a superseded PR open: adds `hive/keep-open`, which the supersession sweep never auto-closes | `pkg/dashboard/api.go:480` | +| `GET` | `/api/review/pipeline` | Dashboard auth/session | Review pipeline: one card per review-queue PR placed in its stage (`unreviewed`, `reviewing`, `changes_requested`, `fixing`, `human_hold`, `approved`, `merged`, `abandoned`) with `since`, `reviewers`, P0–P3 `severity` counts, `loop_count`/`loop_cap`, `next_action` and `reasons`; filtered by `repo` and `stage`, paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:484` | | `GET` | `/api/review/dispatch/events` | Dashboard auth/session | Webhook review dispatch queue: `enabled`, `webhooks_configured`, `debounce_s`, `pending` (soonest first) and `recent` (newest first) dispatches with repo, number, head SHA, action, timestamps and coalesced count, plus `stats` counters; in memory, never calls GitHub | `pkg/dashboard/api_review_event_dispatch.go:16` | -| `GET` | `/api/review/evidence` | Merger or owner | [Review evidence bundle](review-evidence.md) for one PR head. Query `repo` (`owner/name`), `number`, optional `head` (full SHA or unique prefix of at least 7 characters; `409` when ambiguous; default is the newest head), `format=json\|zip` (default `json`) and `download=1` for an attachment. `json` returns the bundle bytes exactly as sealed (verdicts, posted reviews, policy, CI check runs, sentinel findings, human actions, merge event, `hash`, optional `signature`); `zip` adds `verdicts.json`, `review-links.json` and a `manifest.json` of SHA-256s plus the signing public key when readable. `400` for a bad `repo`/`number`; `404` with `status: "expired"`/`expired: true` when retention removed a bundle Hive can prove existed, otherwise `status: "not_found"` | `pkg/dashboard/api.go:486` | -| `GET` | `/api/review/evidence/list` | Merger or owner | Every retained evidence bundle id and head for one PR (`repo`, `number`), oldest first, with the newest marked latest; `400` for a bad `repo`/`number` | `pkg/dashboard/api.go:487` | -| `GET` | `/api/evidence/write-counters` | Merger or owner | Durable attempted vs blocked agent GitHub write counters ([#12069](https://github.com/hivecommons/hive/issues/12069)): `totals` by intent tier (ACMM mode) with `blocked_by_reason`, and `weeks` keyed by the UTC Monday of each ISO week with per-tier and per-reason counts. Persisted across restarts; observation only, never gates a write or merge. Empty `totals`/`weeks` before anything is recorded | `pkg/dashboard/api.go:489` | -| `GET` | `/api/evidence/summary` | Dashboard auth/session | Read-only aggregate over `/data/evidence` bundles (schema v1) plus reviewer accuracy. Query `hours` (default 24, capped like `/api/pr-throughput`; `0` = all time), `repo` (`owner/name`) and `author_kind=human\|agent\|bot`; `400` for a bad value. Returns `outcomes_by_author_kind` (merged/open; `closed` is 0 and `reverted_14d` null until bundles record them), `reviewer_calibration` buckets per perspective, `reviewer_accuracy_by_model` split by author kind, `sentinel_hits_per_week`, `shadow_gate.would_have_held` (0 until the shadow gate lands), `write_counters` (per intent tier `{attempted, blocked}` from the proxy write counter file; omitted when absent or unreadable), the `/api/reviewer/accuracy` summary, and `n` plus `missing` counts (unknown-model verdicts, empty CI, unreadable records). Never consulted by merge decisions. | `pkg/dashboard/api.go:492` | +| `GET` | `/api/review/evidence` | Merger or owner | [Review evidence bundle](review-evidence.md) for one PR head. Query `repo` (`owner/name`), `number`, optional `head` (full SHA or unique prefix of at least 7 characters; `409` when ambiguous; default is the newest head), `format=json\|zip` (default `json`) and `download=1` for an attachment. `json` returns the bundle bytes exactly as sealed (verdicts, posted reviews, policy, CI check runs, sentinel findings, human actions, merge event, `hash`, optional `signature`); `zip` adds `verdicts.json`, `review-links.json` and a `manifest.json` of SHA-256s plus the signing public key when readable. `400` for a bad `repo`/`number`; `404` with `status: "expired"`/`expired: true` when retention removed a bundle Hive can prove existed, otherwise `status: "not_found"` | `pkg/dashboard/api.go:488` | +| `GET` | `/api/review/evidence/list` | Merger or owner | Every retained evidence bundle id and head for one PR (`repo`, `number`), oldest first, with the newest marked latest; `400` for a bad `repo`/`number` | `pkg/dashboard/api.go:489` | +| `GET` | `/api/evidence/write-counters` | Merger or owner | Durable attempted vs blocked agent GitHub write counters ([#12069](https://github.com/hivecommons/hive/issues/12069)): `totals` by intent tier (ACMM mode) with `blocked_by_reason`, and `weeks` keyed by the UTC Monday of each ISO week with per-tier and per-reason counts. Persisted across restarts; observation only, never gates a write or merge. Empty `totals`/`weeks` before anything is recorded | `pkg/dashboard/api.go:491` | +| `GET` | `/api/evidence/summary` | Dashboard auth/session | Read-only aggregate over `/data/evidence` bundles (schema v1) plus reviewer accuracy. Query `hours` (default 24, capped like `/api/pr-throughput`; `0` = all time), `repo` (`owner/name`) and `author_kind=human\|agent\|bot`; `400` for a bad value. Returns `outcomes_by_author_kind` (merged/open; `closed` is 0 and `reverted_14d` null until bundles record them), `reviewer_calibration` buckets per perspective, `reviewer_accuracy_by_model` split by author kind, `sentinel_hits_per_week`, `shadow_gate.would_have_held` (0 until the shadow gate lands), `write_counters` (per intent tier `{attempted, blocked}` from the proxy write counter file; omitted when absent or unreadable), the `/api/reviewer/accuracy` summary, and `n` plus `missing` counts (unknown-model verdicts, empty CI, unreadable records). Never consulted by merge decisions. | `pkg/dashboard/api.go:494` | | `GET` | `/api/agents` | Dashboard auth/session | Agents List | `pkg/dashboard/api.go:301` | | `POST` | `/api/agents` | Owner only | Agent Create | `pkg/dashboard/api.go:302` | | `POST` | `/api/agents/import` | Owner only | Agent Import | `pkg/dashboard/api.go:303` | @@ -345,17 +345,19 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of ` | `POST` | `/api/config/knowledge/connectors/{name}/sync` | Owner only | Start an immediate background sync of one connector | `pkg/dashboard/api.go` | | `GET` | `/api/config/knowledge/agent-scopes` | Dashboard auth/session | Per-agent knowledge scopes (`knowledge.agent_scopes`: layers, repos, types, tags, include_states) ([docs](knowledge-toc.md#per-agent-scopes)) | `pkg/dashboard/api.go` | | `PUT` | `/api/config/knowledge/agent-scopes` | Owner only | Replace `knowledge.agent_scopes` after validating layer and state names; persisted and audited as `config_knowledge_agent_scopes` | `pkg/dashboard/api.go` | -| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:392` | -| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:393` | -| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:394` | -| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:395` | -| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:396` | -| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:397` | -| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:398` | -| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:399` | -| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:400` | -| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:401` | -| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:402` | +| `GET` | `/api/config/knowledge/pipeline` | Owner only | Scalar bead synthesizer, curator, code map and primer settings, plus the read-only engine, vault paths and primer merge strategy ([docs](settings.md#knowledge)) | `pkg/dashboard/api.go` | +| `PUT` | `/api/config/knowledge/pipeline` | Owner only | Update those settings after `config.ValidateKnowledgePipeline`; 0 or empty restores the default; the bead synthesizer switch applies at once, the rest after a restart; audited as `config_knowledge_pipeline` | `pkg/dashboard/api.go` | +| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:394` | +| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:395` | +| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:396` | +| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:397` | +| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:398` | +| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:399` | +| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:400` | +| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:401` | +| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:402` | +| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:403` | +| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:404` | | `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:367` | | `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:368` | | `GET` | `/api/knowledge/connectors` | Dashboard auth/session | Knowledge connector statuses and publish mirror status (`{"connectors": [...], "publish": {...}\|null}`, see [knowledge-connectors.md](knowledge-connectors.md)) | `pkg/dashboard/api_knowledge_connectors.go:23` | @@ -468,63 +470,63 @@ always resolved server-side from the validated token. | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status | `pkg/dashboard/api.go:436` | -| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:437` | -| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:438` | -| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:439` | -| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:440` | -| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:441` | -| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:442` | -| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:443` | -| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:444` | -| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:445` | -| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:446` | -| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:447` | -| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:448` | -| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals | `pkg/dashboard/api.go:449` | -| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos | `pkg/dashboard/api.go:450` | -| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:451` | -| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:452` | -| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:453` | -| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:454` | -| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:455` | -| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:456` | +| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status | `pkg/dashboard/api.go:438` | +| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:439` | +| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:440` | +| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:441` | +| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:442` | +| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:443` | +| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:444` | +| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:445` | +| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:446` | +| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:447` | +| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:448` | +| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:449` | +| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:450` | +| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals | `pkg/dashboard/api.go:451` | +| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos | `pkg/dashboard/api.go:452` | +| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:453` | +| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:454` | +| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:455` | +| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:456` | +| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:457` | +| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:458` | ## Inception | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:407` | -| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:408` | -| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:409` | -| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:410` | -| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:411` | -| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:412` | -| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:413` | -| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:414` | -| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:415` | -| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:416` | -| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:417` | -| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:418` | -| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:419` | -| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:420` | +| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:409` | +| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:410` | +| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:411` | +| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:412` | +| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:413` | +| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:414` | +| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:415` | +| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:416` | +| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:417` | +| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:418` | +| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:419` | +| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:420` | +| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:421` | +| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:422` | ## Beads | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:458` | -| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:459` | -| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:460` | -| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:461` | -| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:462` | +| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:460` | +| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:461` | +| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:462` | +| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:463` | +| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:464` | ## Dashboard miscellaneous | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| | `GET` | `/api/audit` | Read-write role | Audit Log — `{"entries": [...]}` envelope, newest first, capped at 200; response shape and the serve-time `user_name` field in [audit-log.md](audit-log.md#get-apiaudit) | `pkg/dashboard/api.go:53` | -| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) — total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe → Heal `promotion` hint; see [agent-watchdog.md](agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:469` | +| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) — total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe → Heal `promotion` hint; see [agent-watchdog.md](agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:471` | | `POST` | `/api/presence` | Dashboard auth/session | Presence | `pkg/dashboard/api.go:56` | | `GET` | `/api/prompt-history` | Dashboard auth/session | Prompt History | `pkg/dashboard/api.go:57` | | `POST` | `/api/self-upgrade` | Owner only | Self Upgrade | `pkg/dashboard/api.go:58` | @@ -538,11 +540,11 @@ always resolved server-side from the validated token. | `GET` | `/api/convergence/soak` | Owner only | Convergence Soak Status | `pkg/dashboard/api.go:245` | -| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:425` | -| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:428` | -| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:429` | -| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:431` | -| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:432` | +| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:427` | +| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:430` | +| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:431` | +| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:433` | +| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:434` | | `POST` | `/api/linear/agent/install` | Owner only | Linear Agent Install (OAuth start) | `pkg/dashboard/api_linear_agent.go:215` | | `GET` | `/api/linear/agent/status` | Owner only | Linear Agent Status | `pkg/dashboard/api_linear_agent.go:216` | | `POST` | `/api/linear/agent/disconnect` | Owner only | Linear Agent Disconnect | `pkg/dashboard/api_linear_agent.go:217` | @@ -558,10 +560,10 @@ always resolved server-side from the validated token. | `GET` | `/api/summaries` | Dashboard auth/session | Summaries | `pkg/dashboard/api.go:177` | | `POST` | `/api/prs/{owner}/{repo}/{number}/queue-automerge` | Merger/owner role | Queue PRAuto Merge | `pkg/dashboard/api.go:178` | | `GET` | `/api/inference/models/{backend}` | Dashboard auth/session | Inference Models | `pkg/dashboard/api.go:344` | -| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:404` | -| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:405` | -| `POST` | `/api/chat` | Dashboard auth/session | Chat | `pkg/dashboard/api.go:434` | -| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:464` | +| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:406` | +| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:407` | +| `POST` | `/api/chat` | Dashboard auth/session | Chat | `pkg/dashboard/api.go:436` | +| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:466` | | `GET` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:236` | | `POST` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:237` | | `GET` | `/api/docs` | Dashboard auth/session | APIDocs | `pkg/dashboard/api_contribute.go:238` | diff --git a/src/docs/settings.md b/src/docs/settings.md index 3ad4bed449..aef0e06251 100644 --- a/src/docs/settings.md +++ b/src/docs/settings.md @@ -67,6 +67,10 @@ Optional capabilities, each off or inert until you turn it on. Where a feature h The other features on this tab (OpenTelemetry export, external execution, auto-plan, issue claims, PR follow-up, long-running runs, provider rotation, merge recommendations, convergence rollout, stall replan, and question auto-close) are explained by their **?** tooltips, and their configuration keys are listed in the [Hive operator reference](operator-reference.md#configuration-blocks). +## Knowledge + +Whether the public read-only knowledge MCP endpoint is on, the knowledge pipeline, and the external knowledge connectors. The **Knowledge pipeline** block sets the bead synthesizer (schedule, target layer, minimum confidence, per-cycle fact and GitHub API caps, bead retention), scheduled curator promotion (schedule, threshold, source and target layers), repository code maps (schedule, layer, size cap) and the kick primer's fact and byte caps. Turning the bead synthesizer on or off applies at once; the other pipeline settings apply after a restart, and 0 or empty restores the default. The engine, the vault paths and the primer merge strategy are shown read-only because they are set in `hive.yaml`. The repositories to map and the primer priority list stay yaml-only. See [Knowledge curator](knowledge-curator.md) and [Knowledge connectors](knowledge-connectors.md). + ## Advisory How many advisory findings are shown, how often they refresh, and when a finding counts as stale. See [Advisory](advisory.md) and [Advisory staleness](advisory-staleness.md). diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go index e7c6337ef5..37888ff4f5 100644 --- a/src/pkg/config/config.go +++ b/src/pkg/config/config.go @@ -6479,11 +6479,11 @@ const ( defaultGitHubAPIReserve = DefaultGitHubAPIReserve defaultGitHubAPICritical = DefaultGitHubAPICritical defaultPollIntervalMins = 5 - defaultKnowledgeMaxFacts = 25 - defaultKnowledgeMaxBytes = 4096 + defaultKnowledgeMaxFacts = DefaultKnowledgePrimerMaxFacts + defaultKnowledgeMaxBytes = DefaultKnowledgePrimerMaxBytes defaultKnowledgeEngine = "llm-wiki" - defaultCuratorSchedule = "daily" - defaultPromoteThreshold = 0.9 + defaultCuratorSchedule = DefaultCuratorSchedule + defaultPromoteThreshold = DefaultCuratorAutoPromoteThreshold defaultSensingTTLSeconds = 900 defaultSensingPullbackSeconds = 900 defaultBudgetPeriodDays = 7 @@ -6884,7 +6884,7 @@ func (c *Config) applyDefaults() { c.Knowledge.Primer.MaxBytes = defaultKnowledgeMaxBytes } if c.Knowledge.Primer.MergeStrategy == "" { - c.Knowledge.Primer.MergeStrategy = "precedence" + c.Knowledge.Primer.MergeStrategy = DefaultKnowledgePrimerMergeStrategy } if len(c.Knowledge.Primer.Priority) == 0 { c.Knowledge.Primer.Priority = []string{"regression", "gotcha", "test_scaffold", "pattern", "decision"} @@ -6902,22 +6902,22 @@ func (c *Config) applyDefaults() { c.Knowledge.Curator.AutoPromoteThreshold = defaultPromoteThreshold } if c.Knowledge.BeadSynthesizer.Schedule == "" { - c.Knowledge.BeadSynthesizer.Schedule = "hourly" + c.Knowledge.BeadSynthesizer.Schedule = DefaultBeadSynthSchedule } if c.Knowledge.BeadSynthesizer.MinConfidence == 0 { - c.Knowledge.BeadSynthesizer.MinConfidence = 0.5 + c.Knowledge.BeadSynthesizer.MinConfidence = DefaultBeadSynthMinConfidence } if c.Knowledge.BeadSynthesizer.TargetLayer == "" { - c.Knowledge.BeadSynthesizer.TargetLayer = "project" + c.Knowledge.BeadSynthesizer.TargetLayer = DefaultBeadSynthTargetLayer } if c.Knowledge.BeadSynthesizer.MaxFactsPerCycle == 0 { - c.Knowledge.BeadSynthesizer.MaxFactsPerCycle = 20 + c.Knowledge.BeadSynthesizer.MaxFactsPerCycle = DefaultBeadSynthMaxFactsPerCycle } if c.Knowledge.BeadSynthesizer.MaxAPICallsPerCycle == 0 { c.Knowledge.BeadSynthesizer.MaxAPICallsPerCycle = DefaultBeadSynthMaxAPICallsPerCycle } if c.Knowledge.BeadSynthesizer.VaultPath == "" { - c.Knowledge.BeadSynthesizer.VaultPath = "/data/vaults/bead-synth-wiki" + c.Knowledge.BeadSynthesizer.VaultPath = DefaultBeadSynthVaultPath } } } diff --git a/src/pkg/config/knowledge_config.go b/src/pkg/config/knowledge_config.go index ed84c42cb3..7b95b54974 100644 --- a/src/pkg/config/knowledge_config.go +++ b/src/pkg/config/knowledge_config.go @@ -72,6 +72,99 @@ type BeadSynthesizerConfig struct { // DefaultBeadSynthMaxAPICallsPerCycle is the default per-cycle PR GET cap. const DefaultBeadSynthMaxAPICallsPerCycle = 40 +// Knowledge pipeline defaults that applyDefaults sets at Load; runtime editors +// substitute the same values so a cleared field restores the default. +const ( + DefaultBeadSynthSchedule = "hourly" + DefaultBeadSynthMinConfidence = 0.5 + DefaultBeadSynthTargetLayer = "project" + DefaultBeadSynthMaxFactsPerCycle = 20 + DefaultBeadSynthVaultPath = "/data/vaults/bead-synth-wiki" + DefaultCuratorSchedule = "daily" + DefaultCuratorAutoPromoteThreshold = 0.9 + DefaultKnowledgePrimerMaxFacts = 25 + DefaultKnowledgePrimerMaxBytes = 4096 + DefaultKnowledgePrimerMergeStrategy = "precedence" +) + +// KnowledgeSchedules are the schedule values the bead synthesizer, curator +// and code map loops understand (knowledge.ParseSynthSchedule). +var KnowledgeSchedules = []string{"hourly", "daily"} + +// ValidateKnowledgePipeline checks the scalar bead_synthesizer, curator, +// code_maps and primer keys the dashboard edits: schedules must be hourly or +// daily (empty uses the default), layers must be personal, project, org or +// community, confidences lie in [0, 1], counts and byte caps are not +// negative, and scheduled promotion may not promote a layer into itself. +// Unset (zero) fields pass, so callers can validate just the keys they edit. +func ValidateKnowledgePipeline(k KnowledgeConfig) error { + for _, s := range []struct{ field, v string }{ + {"knowledge.bead_synthesizer.schedule", k.BeadSynthesizer.Schedule}, + {"knowledge.curator.schedule", k.Curator.Schedule}, + {"knowledge.code_maps.schedule", k.CodeMaps.Schedule}, + } { + v := strings.ToLower(strings.TrimSpace(s.v)) + if v != "" && v != "hourly" && v != "daily" { + return fmt.Errorf("%s %q must be hourly or daily", s.field, s.v) + } + } + for _, l := range []struct{ field, v string }{ + {"knowledge.bead_synthesizer.target_layer", k.BeadSynthesizer.TargetLayer}, + {"knowledge.curator.promote_from", k.Curator.PromoteFrom}, + {"knowledge.curator.promote_to", k.Curator.PromoteTo}, + {"knowledge.code_maps.layer", k.CodeMaps.Layer}, + } { + if v := strings.TrimSpace(l.v); v != "" && !knowledgeLayerNames[v] { + return fmt.Errorf("%s %q must be personal, project, org or community", l.field, l.v) + } + } + from, to := strings.TrimSpace(k.Curator.PromoteFrom), strings.TrimSpace(k.Curator.PromoteTo) + if from == "" { + from = "project" + } + if to == "" { + to = "org" + } + if from == to { + return fmt.Errorf("knowledge.curator.promote_from and promote_to must name different layers (both %q)", from) + } + for _, f := range []struct { + field string + v float64 + }{ + {"knowledge.bead_synthesizer.min_confidence", k.BeadSynthesizer.MinConfidence}, + {"knowledge.curator.auto_promote_threshold", k.Curator.AutoPromoteThreshold}, + } { + if f.v < 0 || f.v > 1 { + return fmt.Errorf("%s must be between 0 and 1", f.field) + } + } + type intField struct { + field string + v int + } + ints := []intField{ + {"knowledge.bead_synthesizer.max_facts_per_cycle", k.BeadSynthesizer.MaxFactsPerCycle}, + {"knowledge.bead_synthesizer.max_api_calls_per_cycle", k.BeadSynthesizer.MaxAPICallsPerCycle}, + {"knowledge.code_maps.max_bytes", k.CodeMaps.MaxBytes}, + {"knowledge.primer.max_facts", k.Primer.MaxFacts}, + {"knowledge.primer.max_bytes", k.Primer.MaxBytes}, + } + if rp := k.BeadSynthesizer.RetentionPolicy; rp != nil { + ints = append(ints, + intField{"knowledge.bead_synthesizer.retention_policy.max_beads", rp.MaxBeads}, + intField{"knowledge.bead_synthesizer.retention_policy.archive_after_synth_days", rp.ArchiveAfterSynthDays}, + intField{"knowledge.bead_synthesizer.retention_policy.high_priority_retain_days", rp.HighPriorityRetainDays}, + ) + } + for _, f := range ints { + if f.v < 0 { + return fmt.Errorf("%s must be 0 (default) or greater", f.field) + } + } + return nil +} + // EffectiveMaxAPICallsPerCycle returns the configured cap or the default. func (b BeadSynthesizerConfig) EffectiveMaxAPICallsPerCycle() int { if b.MaxAPICallsPerCycle <= 0 { diff --git a/src/pkg/dashboard/api.go b/src/pkg/dashboard/api.go index 08b6fa4992..90568a60d9 100644 --- a/src/pkg/dashboard/api.go +++ b/src/pkg/dashboard/api.go @@ -389,6 +389,8 @@ func (s *Server) RegisterAPI(deps *Dependencies) { s.mux.HandleFunc("POST /api/config/knowledge/connectors/{name}/sync", s.handleKnowledgeConnectorsSync) s.mux.HandleFunc("GET /api/config/knowledge/agent-scopes", s.handleKnowledgeAgentScopesGet) s.mux.HandleFunc("PUT /api/config/knowledge/agent-scopes", s.handleKnowledgeAgentScopesPut) + s.mux.HandleFunc("GET /api/config/knowledge/pipeline", s.handleKnowledgePipelineGet) + s.mux.HandleFunc("PUT /api/config/knowledge/pipeline", s.handleKnowledgePipelinePut) s.mux.HandleFunc("GET /api/knowledge/git-sources", s.handleGitSourcesList) s.mux.HandleFunc("POST /api/knowledge/git-sources", s.handleGitSourcesConnect) s.mux.HandleFunc("DELETE /api/knowledge/git-sources", s.handleGitSourcesDisconnect) diff --git a/src/pkg/dashboard/api_config_knowledge_pipeline.go b/src/pkg/dashboard/api_config_knowledge_pipeline.go new file mode 100644 index 0000000000..f077e6d3e9 --- /dev/null +++ b/src/pkg/dashboard/api_config_knowledge_pipeline.go @@ -0,0 +1,338 @@ +package dashboard + +import ( + "net/http" + "strings" + + "github.com/hivecommons/hive/pkg/config" +) + +// knowledgePipelineBody is the PUT /api/config/knowledge/pipeline request: +// the scalar knowledge.bead_synthesizer, knowledge.curator, +// knowledge.code_maps and knowledge.primer keys. Every field is a pointer so +// an absent key leaves the config untouched; 0 or "" restores the default. +// Paths the hive writes to (the vault paths) and the engine are display-only +// and not accepted here. +type knowledgePipelineBody struct { + BeadSynthesizer *struct { + Enabled *bool `json:"enabled"` + Schedule *string `json:"schedule"` + MinConfidence *float64 `json:"min_confidence"` + TargetLayer *string `json:"target_layer"` + MaxFactsPerCycle *int `json:"max_facts_per_cycle"` + MaxAPICallsPerCycle *int `json:"max_api_calls_per_cycle"` + RetentionPolicy *struct { + MaxBeads *int `json:"max_beads"` + ArchiveAfterSynthDays *int `json:"archive_after_synth_days"` + HighPriorityRetainDays *int `json:"high_priority_retain_days"` + PreserveWithDeps *bool `json:"preserve_with_deps"` + } `json:"retention_policy"` + } `json:"bead_synthesizer"` + Curator *struct { + Enabled *bool `json:"enabled"` + Schedule *string `json:"schedule"` + AutoPromoteThreshold *float64 `json:"auto_promote_threshold"` + PromoteFrom *string `json:"promote_from"` + PromoteTo *string `json:"promote_to"` + } `json:"curator"` + CodeMaps *struct { + Enabled *bool `json:"enabled"` + Schedule *string `json:"schedule"` + Layer *string `json:"layer"` + MaxBytes *int `json:"max_bytes"` + } `json:"code_maps"` + Primer *struct { + MaxFacts *int `json:"max_facts"` + MaxBytes *int `json:"max_bytes"` + } `json:"primer"` +} + +func trimLower(p *string) string { return strings.ToLower(strings.TrimSpace(*p)) } + +// prepare validates the request with config.ValidateKnowledgePipeline and +// returns the change to apply. Only the keys the request carries are +// validated, so a value already in hive.yaml that this editor would reject +// does not block saving an unrelated field. +func (b knowledgePipelineBody) prepare(cur config.KnowledgeConfig) (func(*config.KnowledgeConfig), error) { + var probe config.KnowledgeConfig + var steps []func(*config.KnowledgeConfig) + if bs := b.BeadSynthesizer; bs != nil { + if bs.Enabled != nil { + v := *bs.Enabled + steps = append(steps, func(k *config.KnowledgeConfig) { k.BeadSynthesizer.Enabled = &v }) + } + if bs.Schedule != nil { + v := trimLower(bs.Schedule) + probe.BeadSynthesizer.Schedule = v + if v == "" { + v = config.DefaultBeadSynthSchedule + } + steps = append(steps, func(k *config.KnowledgeConfig) { k.BeadSynthesizer.Schedule = v }) + } + if bs.MinConfidence != nil { + v := *bs.MinConfidence + probe.BeadSynthesizer.MinConfidence = v + if v == 0 { + v = config.DefaultBeadSynthMinConfidence + } + steps = append(steps, func(k *config.KnowledgeConfig) { k.BeadSynthesizer.MinConfidence = v }) + } + if bs.TargetLayer != nil { + v := trimLower(bs.TargetLayer) + probe.BeadSynthesizer.TargetLayer = v + if v == "" { + v = config.DefaultBeadSynthTargetLayer + } + steps = append(steps, func(k *config.KnowledgeConfig) { k.BeadSynthesizer.TargetLayer = v }) + } + if bs.MaxFactsPerCycle != nil { + v := *bs.MaxFactsPerCycle + probe.BeadSynthesizer.MaxFactsPerCycle = v + if v == 0 { + v = config.DefaultBeadSynthMaxFactsPerCycle + } + steps = append(steps, func(k *config.KnowledgeConfig) { k.BeadSynthesizer.MaxFactsPerCycle = v }) + } + if bs.MaxAPICallsPerCycle != nil { + v := *bs.MaxAPICallsPerCycle + probe.BeadSynthesizer.MaxAPICallsPerCycle = v + if v == 0 { + v = config.DefaultBeadSynthMaxAPICallsPerCycle + } + steps = append(steps, func(k *config.KnowledgeConfig) { k.BeadSynthesizer.MaxAPICallsPerCycle = v }) + } + if rp := bs.RetentionPolicy; rp != nil { + probe.BeadSynthesizer.RetentionPolicy = &config.RetentionPolicy{} + if rp.MaxBeads != nil { + probe.BeadSynthesizer.RetentionPolicy.MaxBeads = *rp.MaxBeads + } + if rp.ArchiveAfterSynthDays != nil { + probe.BeadSynthesizer.RetentionPolicy.ArchiveAfterSynthDays = *rp.ArchiveAfterSynthDays + } + if rp.HighPriorityRetainDays != nil { + probe.BeadSynthesizer.RetentionPolicy.HighPriorityRetainDays = *rp.HighPriorityRetainDays + } + r := *rp + steps = append(steps, func(k *config.KnowledgeConfig) { + // An absent block boots with preserve_with_deps on, so a block + // created here starts from that rather than the zero value. + if k.BeadSynthesizer.RetentionPolicy == nil { + k.BeadSynthesizer.RetentionPolicy = &config.RetentionPolicy{PreserveWithDeps: true} + } + p := k.BeadSynthesizer.RetentionPolicy + if r.MaxBeads != nil { + p.MaxBeads = *r.MaxBeads + } + if r.ArchiveAfterSynthDays != nil { + p.ArchiveAfterSynthDays = *r.ArchiveAfterSynthDays + } + if r.HighPriorityRetainDays != nil { + p.HighPriorityRetainDays = *r.HighPriorityRetainDays + } + if r.PreserveWithDeps != nil { + p.PreserveWithDeps = *r.PreserveWithDeps + } + }) + } + } + if c := b.Curator; c != nil { + if c.Enabled != nil { + v := *c.Enabled + steps = append(steps, func(k *config.KnowledgeConfig) { k.Curator.Enabled = &v }) + } + if c.Schedule != nil { + v := trimLower(c.Schedule) + probe.Curator.Schedule = v + steps = append(steps, func(k *config.KnowledgeConfig) { k.Curator.Schedule = v }) + } + if c.AutoPromoteThreshold != nil { + v := *c.AutoPromoteThreshold + probe.Curator.AutoPromoteThreshold = v + if v == 0 { + v = config.DefaultCuratorAutoPromoteThreshold + } + steps = append(steps, func(k *config.KnowledgeConfig) { k.Curator.AutoPromoteThreshold = v }) + } + if c.PromoteFrom != nil || c.PromoteTo != nil { + from, to := cur.Curator.PromoteFrom, cur.Curator.PromoteTo + if c.PromoteFrom != nil { + from = trimLower(c.PromoteFrom) + } + if c.PromoteTo != nil { + to = trimLower(c.PromoteTo) + } + probe.Curator.PromoteFrom, probe.Curator.PromoteTo = from, to + steps = append(steps, func(k *config.KnowledgeConfig) { k.Curator.PromoteFrom, k.Curator.PromoteTo = from, to }) + } + } + if cm := b.CodeMaps; cm != nil { + if cm.Enabled != nil { + v := *cm.Enabled + steps = append(steps, func(k *config.KnowledgeConfig) { k.CodeMaps.Enabled = &v }) + } + // Code map zero values are already the defaults (hourly, project, + // the generator's byte cap), so they are stored as-is. + if cm.Schedule != nil { + v := trimLower(cm.Schedule) + probe.CodeMaps.Schedule = v + steps = append(steps, func(k *config.KnowledgeConfig) { k.CodeMaps.Schedule = v }) + } + if cm.Layer != nil { + v := trimLower(cm.Layer) + probe.CodeMaps.Layer = v + steps = append(steps, func(k *config.KnowledgeConfig) { k.CodeMaps.Layer = v }) + } + if cm.MaxBytes != nil { + v := *cm.MaxBytes + probe.CodeMaps.MaxBytes = v + steps = append(steps, func(k *config.KnowledgeConfig) { k.CodeMaps.MaxBytes = v }) + } + } + if p := b.Primer; p != nil { + if p.MaxFacts != nil { + v := *p.MaxFacts + probe.Primer.MaxFacts = v + if v == 0 { + v = config.DefaultKnowledgePrimerMaxFacts + } + steps = append(steps, func(k *config.KnowledgeConfig) { k.Primer.MaxFacts = v }) + } + if p.MaxBytes != nil { + v := *p.MaxBytes + probe.Primer.MaxBytes = v + if v == 0 { + v = config.DefaultKnowledgePrimerMaxBytes + } + steps = append(steps, func(k *config.KnowledgeConfig) { k.Primer.MaxBytes = v }) + } + } + if err := config.ValidateKnowledgePipeline(probe); err != nil { + return nil, err + } + return func(k *config.KnowledgeConfig) { + for _, step := range steps { + step(k) + } + }, nil +} + +func knowledgePipelineResponse(cfg *config.Config) map[string]interface{} { + k := cfg.Knowledge + rp := config.RetentionPolicy{PreserveWithDeps: true} + if k.BeadSynthesizer.RetentionPolicy != nil { + rp = *k.BeadSynthesizer.RetentionPolicy + } + return map[string]interface{}{ + // Display-only: the engine picks the knowledge backend at boot. + "engine": k.Engine, + "bead_synthesizer": map[string]interface{}{ + "enabled": k.BeadSynthesizer.IsEnabled(), + "schedule": k.BeadSynthesizer.Schedule, + "min_confidence": k.BeadSynthesizer.MinConfidence, + "target_layer": k.BeadSynthesizer.TargetLayer, + "max_facts_per_cycle": k.BeadSynthesizer.MaxFactsPerCycle, + "max_api_calls_per_cycle": k.BeadSynthesizer.MaxAPICallsPerCycle, + // Display-only: a directory the hive creates and writes facts to. + "vault_path": k.BeadSynthesizer.VaultPath, + "retention_policy": map[string]interface{}{ + "max_beads": rp.MaxBeads, + "archive_after_synth_days": rp.ArchiveAfterSynthDays, + "high_priority_retain_days": rp.HighPriorityRetainDays, + "preserve_with_deps": rp.PreserveWithDeps, + }, + }, + "curator": map[string]interface{}{ + "enabled": k.Curator.IsEnabled(), + "schedule": k.Curator.Schedule, + "auto_promote_threshold": k.Curator.AutoPromoteThreshold, + "promote_from": k.Curator.PromoteFrom, + "promote_to": k.Curator.PromoteTo, + }, + "code_maps": map[string]interface{}{ + "enabled": k.CodeMaps.IsEnabled(), + "schedule": k.CodeMaps.Schedule, + "layer": k.CodeMaps.Layer, + "max_bytes": k.CodeMaps.MaxBytes, + // Display-only, like the bead synthesizer vault path. + "vault_path": k.CodeMaps.VaultPath, + }, + "primer": map[string]interface{}{ + "max_facts": k.Primer.MaxFacts, + "max_bytes": k.Primer.MaxBytes, + // Display-only: precedence is the only implemented strategy. + "merge_strategy": k.Primer.MergeStrategy, + }, + "schedules": config.KnowledgeSchedules, + "layers": []string{"personal", "project", "org", "community"}, + "defaults": map[string]interface{}{ + "bead_synthesizer_schedule": config.DefaultBeadSynthSchedule, + "bead_synthesizer_min_confidence": config.DefaultBeadSynthMinConfidence, + "bead_synthesizer_target_layer": config.DefaultBeadSynthTargetLayer, + "bead_synthesizer_max_facts_per_cycle": config.DefaultBeadSynthMaxFactsPerCycle, + "bead_synthesizer_max_api_calls_per_cycle": config.DefaultBeadSynthMaxAPICallsPerCycle, + "bead_synthesizer_vault_path": config.DefaultBeadSynthVaultPath, + "curator_schedule": config.DefaultCuratorSchedule, + "curator_auto_promote_threshold": config.DefaultCuratorAutoPromoteThreshold, + "curator_promote_from": "project", + "curator_promote_to": "org", + "primer_max_facts": config.DefaultKnowledgePrimerMaxFacts, + "primer_max_bytes": config.DefaultKnowledgePrimerMaxBytes, + "primer_merge_strategy": config.DefaultKnowledgePrimerMergeStrategy, + }, + } +} + +// handleKnowledgePipelineGet returns the scalar knowledge pipeline settings +// for Settings → Knowledge. OWNER-ONLY, like the rest of the config editors. +func (s *Server) handleKnowledgePipelineGet(w http.ResponseWriter, r *http.Request) { + if !requireOwnerRole(w, r) { + return + } + if s.deps == nil || s.deps.Config == nil { + jsonError(w, "config unavailable", http.StatusServiceUnavailable) + return + } + jsonResponse(w, knowledgePipelineResponse(s.deps.Config)) +} + +// handleKnowledgePipelinePut updates the scalar bead synthesizer, curator, +// code map and primer keys (owner-only). The request is validated before +// anything is applied, then persisted and audited. The bead synthesizer +// switch starts or stops the running synthesizer at once, as +// PUT /api/knowledge/bead-synthesizer/enabled does; every other key is read +// when the hive boots, so it applies after a restart. +func (s *Server) handleKnowledgePipelinePut(w http.ResponseWriter, r *http.Request) { + if !requireOwnerRole(w, r) { + return + } + if s.deps == nil || s.deps.Config == nil { + jsonError(w, "config unavailable", http.StatusServiceUnavailable) + return + } + var body knowledgePipelineBody + if err := decodeBody(r, &body); err != nil { + jsonError(w, "invalid body", http.StatusBadRequest) + return + } + apply, err := body.prepare(s.deps.Config.Knowledge) + if err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + apply(&s.deps.Config.Knowledge) + + if bs := body.BeadSynthesizer; bs != nil && bs.Enabled != nil && s.deps.BeadSynthesizer != nil { + if *bs.Enabled { + s.deps.BeadSynthesizer.StartBackground(s.deps.Ctx) + } else { + s.deps.BeadSynthesizer.Stop() + } + } + + if err := s.saveConfig(); err != nil { + s.logger.Error("failed to persist config after knowledge pipeline update", "error", err) + } + s.auditFromRequest(r, "config_knowledge_pipeline", auditDetail("section", "knowledge_pipeline"), "") + s.refreshAndPersist() + jsonResponse(w, knowledgePipelineResponse(s.deps.Config)) +} diff --git a/src/pkg/dashboard/knowledge_settings_parity_test.go b/src/pkg/dashboard/knowledge_settings_parity_test.go new file mode 100644 index 0000000000..95d26b3bd2 --- /dev/null +++ b/src/pkg/dashboard/knowledge_settings_parity_test.go @@ -0,0 +1,293 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/config" +) + +const knowledgePipelinePath = "/api/config/knowledge/pipeline" + +// The scalar knowledge pipeline keys round-trip through +// PUT /api/config/knowledge/pipeline; invalid values are rejected without +// changing anything, a partial PUT changes only what it carries, a cleared +// field restores the default, and the display-only keys are not accepted. +func TestKnowledgePipelineParityBatch8RoundTrip(t *testing.T) { + s := covApiServer(t) + k := &s.deps.Config.Knowledge + k.Engine = "llm-wiki" + k.BeadSynthesizer = config.BeadSynthesizerConfig{ + Schedule: "hourly", MinConfidence: 0.5, TargetLayer: "project", + MaxFactsPerCycle: 20, MaxAPICallsPerCycle: 40, VaultPath: "/data/vaults/bead-synth-wiki", + } + k.Curator = config.KnowledgeCurator{AutoPromoteThreshold: 0.9} + k.Primer = config.KnowledgePrimer{MaxFacts: 25, MaxBytes: 4096, MergeStrategy: "precedence"} + before := *k + + for _, bad := range []map[string]any{ + {"bead_synthesizer": map[string]any{"schedule": "weekly"}}, + {"bead_synthesizer": map[string]any{"min_confidence": 1.5}}, + {"bead_synthesizer": map[string]any{"min_confidence": -0.1}}, + {"bead_synthesizer": map[string]any{"target_layer": "galaxy"}}, + {"bead_synthesizer": map[string]any{"max_facts_per_cycle": -1}}, + {"bead_synthesizer": map[string]any{"max_api_calls_per_cycle": -1}}, + {"bead_synthesizer": map[string]any{"retention_policy": map[string]any{"max_beads": -1}}}, + {"bead_synthesizer": map[string]any{"retention_policy": map[string]any{"archive_after_synth_days": -1}}}, + {"bead_synthesizer": map[string]any{"retention_policy": map[string]any{"high_priority_retain_days": -1}}}, + {"curator": map[string]any{"schedule": "monthly"}}, + {"curator": map[string]any{"auto_promote_threshold": 2}}, + {"curator": map[string]any{"promote_from": "org"}}, + {"curator": map[string]any{"promote_from": "community", "promote_to": "community"}}, + {"curator": map[string]any{"promote_to": "nowhere"}}, + {"code_maps": map[string]any{"schedule": "often"}}, + {"code_maps": map[string]any{"layer": "galaxy"}}, + {"code_maps": map[string]any{"max_bytes": -1}}, + {"primer": map[string]any{"max_facts": -1}}, + {"primer": map[string]any{"max_bytes": -1}}, + {"curator": map[string]any{"enabled": true}, "primer": map[string]any{"max_facts": -3}}, + } { + if rec := doPut(s, knowledgePipelinePath, bad); rec.Code != http.StatusBadRequest { + t.Fatalf("%v: expected 400, got %d — %s", bad, rec.Code, rec.Body.String()) + } + } + if k.Curator.IsEnabled() || k.BeadSynthesizer.Schedule != before.BeadSynthesizer.Schedule || + k.BeadSynthesizer.RetentionPolicy != nil || k.Primer.MaxFacts != before.Primer.MaxFacts { + t.Fatal("a rejected PUT changed the config") + } + + rec := doPut(s, knowledgePipelinePath, map[string]any{ + "bead_synthesizer": map[string]any{ + "enabled": false, + "schedule": " Daily ", + "min_confidence": 0.7, + "target_layer": "org", + "max_facts_per_cycle": 5, + "max_api_calls_per_cycle": 10, + "retention_policy": map[string]any{"max_beads": 1000, "archive_after_synth_days": 3}, + }, + "curator": map[string]any{ + "enabled": true, + "schedule": "hourly", + "auto_promote_threshold": 0.8, + "promote_from": "personal", + "promote_to": "project", + }, + "code_maps": map[string]any{"enabled": true, "schedule": "daily", "layer": "org", "max_bytes": 8192}, + "primer": map[string]any{"max_facts": 10, "max_bytes": 2048}, + }) + if rec.Code != http.StatusOK { + t.Fatalf("pipeline PUT: %d — %s", rec.Code, rec.Body.String()) + } + bs := k.BeadSynthesizer + if bs.IsEnabled() || bs.Schedule != "daily" || bs.MinConfidence != 0.7 || bs.TargetLayer != "org" || + bs.MaxFactsPerCycle != 5 || bs.MaxAPICallsPerCycle != 10 { + t.Fatalf("bead synthesizer settings not applied: %+v", bs) + } + // A retention block created here keeps the boot default of preserving + // beads with open dependents. + if rp := bs.RetentionPolicy; rp == nil || rp.MaxBeads != 1000 || rp.ArchiveAfterSynthDays != 3 || !rp.PreserveWithDeps { + t.Fatalf("retention policy not applied: %+v", bs.RetentionPolicy) + } + if c := k.Curator; !c.IsEnabled() || c.Schedule != "hourly" || c.AutoPromoteThreshold != 0.8 || + c.PromoteFrom != "personal" || c.PromoteTo != "project" { + t.Fatalf("curator settings not applied: %+v", c) + } + if cm := k.CodeMaps; !cm.IsEnabled() || cm.Schedule != "daily" || cm.Layer != "org" || cm.MaxBytes != 8192 { + t.Fatalf("code map settings not applied: %+v", cm) + } + if k.Primer.MaxFacts != 10 || k.Primer.MaxBytes != 2048 { + t.Fatalf("primer settings not applied: %+v", k.Primer) + } + + var resp struct { + Engine string `json:"engine"` + BeadSynthesizer map[string]any `json:"bead_synthesizer"` + Curator map[string]any `json:"curator"` + CodeMaps map[string]any `json:"code_maps"` + Primer map[string]any `json:"primer"` + Defaults map[string]any `json:"defaults"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.BeadSynthesizer["schedule"] != "daily" || resp.BeadSynthesizer["enabled"] != false || + resp.Curator["promote_to"] != "project" || resp.CodeMaps["max_bytes"] != float64(8192) || + resp.Primer["max_facts"] != float64(10) { + t.Fatalf("response does not reflect the saved settings: %s", rec.Body.String()) + } + rp, _ := resp.BeadSynthesizer["retention_policy"].(map[string]any) + if rp["max_beads"] != float64(1000) || rp["preserve_with_deps"] != true { + t.Fatalf("response retention_policy = %v", rp) + } + + // A partial PUT changes only what it carries; changing one promotion + // layer is checked against the other one already saved. + if rec := doPut(s, knowledgePipelinePath, map[string]any{"curator": map[string]any{"promote_from": "project"}}); rec.Code != http.StatusBadRequest { + t.Fatalf("promote_from equal to the saved promote_to: expected 400, got %d", rec.Code) + } + if rec := doPut(s, knowledgePipelinePath, map[string]any{ + "bead_synthesizer": map[string]any{"retention_policy": map[string]any{"preserve_with_deps": false}}, + }); rec.Code != http.StatusOK { + t.Fatalf("partial PUT: %d — %s", rec.Code, rec.Body.String()) + } + if rp := k.BeadSynthesizer.RetentionPolicy; rp.PreserveWithDeps || rp.MaxBeads != 1000 || + k.BeadSynthesizer.Schedule != "daily" || k.Curator.PromoteFrom != "personal" { + t.Fatalf("partial PUT changed unrelated keys: %+v / %+v", *rp, k.Curator) + } + + // Clearing restores the defaults applyDefaults would set at Load. + if rec := doPut(s, knowledgePipelinePath, map[string]any{ + "bead_synthesizer": map[string]any{ + "schedule": "", "min_confidence": 0, "target_layer": "", + "max_facts_per_cycle": 0, "max_api_calls_per_cycle": 0, + }, + "curator": map[string]any{"auto_promote_threshold": 0, "promote_from": "", "promote_to": ""}, + "code_maps": map[string]any{"schedule": "", "layer": "", "max_bytes": 0}, + "primer": map[string]any{"max_facts": 0, "max_bytes": 0}, + }); rec.Code != http.StatusOK { + t.Fatalf("clearing pipeline settings: %d — %s", rec.Code, rec.Body.String()) + } + bs = k.BeadSynthesizer + if bs.Schedule != config.DefaultBeadSynthSchedule || bs.MinConfidence != config.DefaultBeadSynthMinConfidence || + bs.TargetLayer != config.DefaultBeadSynthTargetLayer || bs.MaxFactsPerCycle != config.DefaultBeadSynthMaxFactsPerCycle || + bs.MaxAPICallsPerCycle != config.DefaultBeadSynthMaxAPICallsPerCycle { + t.Fatalf("cleared bead synthesizer settings did not restore defaults: %+v", bs) + } + if k.Curator.AutoPromoteThreshold != config.DefaultCuratorAutoPromoteThreshold || k.Curator.PromoteFrom != "" || k.Curator.PromoteTo != "" { + t.Fatalf("cleared curator settings did not restore defaults: %+v", k.Curator) + } + if k.CodeMaps.Schedule != "" || k.CodeMaps.Layer != "" || k.CodeMaps.MaxBytes != 0 { + t.Fatalf("cleared code map settings were not reset: %+v", k.CodeMaps) + } + if k.Primer.MaxFacts != config.DefaultKnowledgePrimerMaxFacts || k.Primer.MaxBytes != config.DefaultKnowledgePrimerMaxBytes { + t.Fatalf("cleared primer settings did not restore defaults: %+v", k.Primer) + } + + // The engine, vault paths and merge strategy are display-only: the GET + // reports them and the PUT ignores them. + if rec := doPut(s, knowledgePipelinePath, map[string]any{ + "engine": "file", + "bead_synthesizer": map[string]any{"vault_path": "/etc"}, + "code_maps": map[string]any{"vault_path": "/etc"}, + "primer": map[string]any{"merge_strategy": "union"}, + }); rec.Code != http.StatusOK { + t.Fatalf("display-only PUT: %d — %s", rec.Code, rec.Body.String()) + } + if k.Engine != "llm-wiki" || k.BeadSynthesizer.VaultPath != "/data/vaults/bead-synth-wiki" || k.CodeMaps.VaultPath != "" || + k.Primer.MergeStrategy != "precedence" { + t.Fatalf("pipeline PUT wrote a display-only key: %+v", *k) + } + rec = doOwnerGet(s, knowledgePipelinePath) + if rec.Code != http.StatusOK { + t.Fatalf("pipeline GET: %d", rec.Code) + } + resp.Engine, resp.Defaults = "", nil + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Engine != "llm-wiki" || resp.BeadSynthesizer["vault_path"] != "/data/vaults/bead-synth-wiki" || + resp.Primer["merge_strategy"] != "precedence" || resp.Defaults["primer_max_bytes"] != float64(4096) { + t.Fatalf("GET response: %s", rec.Body.String()) + } +} + +// A value already in hive.yaml that the editor would reject does not block +// saving an unrelated field. +func TestKnowledgePipelineParityBatch8ValidatesOnlyCarriedKeys(t *testing.T) { + s := covApiServer(t) + s.deps.Config.Knowledge.Curator.Schedule = "weekly" + if rec := doPut(s, knowledgePipelinePath, map[string]any{"primer": map[string]any{"max_facts": 12}}); rec.Code != http.StatusOK { + t.Fatalf("unrelated PUT: %d — %s", rec.Code, rec.Body.String()) + } + if s.deps.Config.Knowledge.Primer.MaxFacts != 12 || s.deps.Config.Knowledge.Curator.Schedule != "weekly" { + t.Fatalf("knowledge = %+v", s.deps.Config.Knowledge) + } +} + +// Only a verified owner may read or change the pipeline settings. +func TestKnowledgePipelineParityBatch8OwnerOnly(t *testing.T) { + s := covApiServer(t) + for _, method := range []string{http.MethodGet, http.MethodPut} { + req := httptest.NewRequest(method, knowledgePipelinePath, strings.NewReader(`{"primer":{"max_facts":3}}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hive-Role", "read-write") + rec := httptest.NewRecorder() + s.mux.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("%s as read-write: status = %d, want 403", method, rec.Code) + } + } + if s.deps.Config.Knowledge.Primer.MaxFacts == 3 { + t.Fatal("a non-owner PUT changed the config") + } +} + +// Every knowledge key this batch exposes renders with its YAML-key hint next +// to a control the Save button reads, or read-only with a restart note. +func TestParityBatch8SettingsRenderEveryKey(t *testing.T) { + html := indexHTML(t) + + body := extractJSFunction(t, html, "renderKnowledgePipelineBody") + for _, c := range []struct{ yaml, id string }{ + {"knowledge.bead_synthesizer.enabled", "bs-enabled"}, + {"knowledge.bead_synthesizer.schedule", "bs-schedule"}, + {"knowledge.bead_synthesizer.target_layer", "bs-target_layer"}, + {"knowledge.bead_synthesizer.min_confidence", "bs-min_confidence"}, + {"knowledge.bead_synthesizer.max_facts_per_cycle", "bs-max_facts_per_cycle"}, + {"knowledge.bead_synthesizer.max_api_calls_per_cycle", "bs-max_api_calls_per_cycle"}, + {"knowledge.bead_synthesizer.retention_policy.max_beads", "rp-max_beads"}, + {"knowledge.bead_synthesizer.retention_policy.archive_after_synth_days", "rp-archive_after_synth_days"}, + {"knowledge.bead_synthesizer.retention_policy.high_priority_retain_days", "rp-high_priority_retain_days"}, + {"knowledge.bead_synthesizer.retention_policy.preserve_with_deps", "rp-preserve_with_deps"}, + {"knowledge.curator.enabled", "cu-enabled"}, + {"knowledge.curator.schedule", "cu-schedule"}, + {"knowledge.curator.auto_promote_threshold", "cu-auto_promote_threshold"}, + {"knowledge.curator.promote_from", "cu-promote_from"}, + {"knowledge.curator.promote_to", "cu-promote_to"}, + {"knowledge.code_maps.enabled", "cm-enabled"}, + {"knowledge.code_maps.schedule", "cm-schedule"}, + {"knowledge.code_maps.layer", "cm-layer"}, + {"knowledge.code_maps.max_bytes", "cm-max_bytes"}, + {"knowledge.primer.max_facts", "pr-max_facts"}, + {"knowledge.primer.max_bytes", "pr-max_bytes"}, + } { + if !strings.Contains(body, "('"+c.id+"', ") || !strings.Contains(body, "'"+c.yaml+"'") { + t.Errorf("%s has no pipeline control with its YAML hint (%s)", c.yaml, c.id) + } + } + for _, yaml := range []string{ + "knowledge.bead_synthesizer.vault_path", + "knowledge.code_maps.vault_path", + "knowledge.primer.merge_strategy", + "knowledge.engine", + } { + if !strings.Contains(body, "roRow(") || !strings.Contains(body, "'"+yaml+"'") { + t.Errorf("%s is not shown read-only with its YAML hint", yaml) + } + } + if !strings.Contains(body, "set in hive.yaml; requires restart") { + t.Error("read-only knowledge keys do not say they need a restart") + } + + save := extractJSFunction(t, html, "saveKnowledgePipeline") + if !strings.Contains(save, "'/api/config/knowledge/pipeline'") { + t.Error("saveKnowledgePipeline does not save through PUT /api/config/knowledge/pipeline") + } + for _, readOnly := range []string{"vault_path", "merge_strategy", "engine"} { + if strings.Contains(save, "'"+readOnly+"'") { + t.Errorf("saveKnowledgePipeline sends display-only %s", readOnly) + } + } + + sharing := extractJSFunction(t, html, "renderGovKnowledgeSharing") + if !strings.Contains(sharing, "yamlHint('knowledge.public.enabled')") { + t.Error("knowledge.public.enabled has no YAML hint on the Knowledge tab") + } + if !strings.Contains(sharing, "renderKnowledgePipelineSection()") { + t.Error("the Knowledge tab does not render the pipeline section") + } +} diff --git a/src/pkg/dashboard/owner_only_handlers_deny_test.go b/src/pkg/dashboard/owner_only_handlers_deny_test.go index af33f13e5c..8a94c2d1bb 100644 --- a/src/pkg/dashboard/owner_only_handlers_deny_test.go +++ b/src/pkg/dashboard/owner_only_handlers_deny_test.go @@ -98,6 +98,8 @@ func TestV4OwnerOnlyHandlerGapsRejectUnverifiedOwners(t *testing.T) { {"inception start", http.MethodPost, "/api/inception/start", srv.handleInceptionStart}, {"knowledge agent scopes put", http.MethodPut, "/api/config/knowledge/agent-scopes", srv.handleKnowledgeAgentScopesPut}, {"knowledge connectors put", http.MethodPut, "/api/config/knowledge/connectors", srv.handleKnowledgeConnectorsPut}, + {"knowledge pipeline get", http.MethodGet, "/api/config/knowledge/pipeline", srv.handleKnowledgePipelineGet}, + {"knowledge pipeline put", http.MethodPut, "/api/config/knowledge/pipeline", srv.handleKnowledgePipelinePut}, {"knowledge connectors sync", http.MethodPost, "/api/config/knowledge/connectors/docs/sync", srv.handleKnowledgeConnectorsSync}, {"knowledge connectors validate", http.MethodPost, "/api/config/knowledge/connectors/validate", srv.handleKnowledgeConnectorsValidate}, {"knowledge entry state", http.MethodPut, "/api/knowledge/entry/old-way/state", srv.handleKnowledgeEntryState}, diff --git a/src/pkg/dashboard/settings_parity_allowlist.txt b/src/pkg/dashboard/settings_parity_allowlist.txt index f3c0ca036a..99a659af3a 100644 --- a/src/pkg/dashboard/settings_parity_allowlist.txt +++ b/src/pkg/dashboard/settings_parity_allowlist.txt @@ -286,26 +286,10 @@ knowledge.agent_scopes..layers[] # TODO #11854 knowledge.agent_scopes..repos[] # TODO #11854 knowledge.agent_scopes..tags[] # TODO #11854 knowledge.agent_scopes..types[] # TODO #11854 -knowledge.bead_synthesizer.enabled # TODO #11854 -knowledge.bead_synthesizer.max_api_calls_per_cycle # TODO #12005 -knowledge.bead_synthesizer.max_facts_per_cycle # TODO #11854 -knowledge.bead_synthesizer.min_confidence # TODO #11854 -knowledge.bead_synthesizer.retention_policy.archive_after_synth_days # TODO #11854 -knowledge.bead_synthesizer.retention_policy.high_priority_retain_days # TODO #11854 -knowledge.bead_synthesizer.retention_policy.max_beads # TODO #11854 -knowledge.bead_synthesizer.retention_policy.preserve_with_deps # TODO #11854 -knowledge.bead_synthesizer.schedule # TODO #11854 -knowledge.bead_synthesizer.target_layer # TODO #11854 -knowledge.bead_synthesizer.vault_path # TODO #11854 -knowledge.code_maps.enabled # TODO #11854 -knowledge.code_maps.layer # TODO #11854 -knowledge.code_maps.max_bytes # TODO #11854 knowledge.code_maps.repos[].branch # TODO #11854 knowledge.code_maps.repos[].name # TODO #11854 knowledge.code_maps.repos[].path # TODO #11854 knowledge.code_maps.repos[].url # TODO #11854 -knowledge.code_maps.schedule # TODO #11854 -knowledge.code_maps.vault_path # TODO #11854 knowledge.connectors[].auth.env # TODO #11854 knowledge.connectors[].auth.file # TODO #11854 knowledge.connectors[].enabled # TODO #11854 @@ -314,17 +298,11 @@ knowledge.connectors[].layer # TODO #11854 knowledge.connectors[].name # TODO #11854 knowledge.connectors[].scope. # TODO #11854 knowledge.connectors[].type # TODO #11854 -knowledge.curator.auto_promote_threshold # TODO #11854 -knowledge.curator.enabled # TODO #11854 knowledge.curator.extract_from[] # TODO #11854 -knowledge.curator.promote_from # TODO #11854 -knowledge.curator.promote_to # TODO #11854 -knowledge.curator.schedule # TODO #11854 knowledge.documents[].file_path # TODO #11854 knowledge.documents[].layer # TODO #11854 knowledge.documents[].name # TODO #11854 knowledge.documents[].url # TODO #11854 -knowledge.engine # TODO #11854 knowledge.git_sources[].branch # TODO #11854 knowledge.git_sources[].layer # TODO #11854 knowledge.git_sources[].name # TODO #11854 @@ -334,11 +312,7 @@ knowledge.layers[].path # TODO #11854 knowledge.layers[].shared # TODO #11854 knowledge.layers[].type # TODO #11854 knowledge.layers[].url # TODO #11854 -knowledge.primer.max_bytes # TODO #11854 -knowledge.primer.max_facts # TODO #11854 -knowledge.primer.merge_strategy # TODO #11854 knowledge.primer.priority[] # TODO #11854 -knowledge.public.enabled # TODO #11854 knowledge.public.tags[] # TODO #11854 knowledge.publish.connector # TODO #11854 knowledge.publish.dry_run # TODO #11854 diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index 4e75f10b80..5ab9957498 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -33015,6 +33015,7 @@

Configure LiteLLM

{ tab: 'Thresholds', section: 'Thresholds', label: 'Delivery SLA targets', key: 'sla.targets', text: 'delivery sla targets sla.targets.start_to_pr_h sla.targets.pr_to_merge_h sla.targets.open_to_merge_h hours sparkline overview stale in-progress' }, { tab: 'Knowledge', section: 'Knowledge Integrations', label: 'Context7 connector', key: 'knowledge.connectors', lazy: true, text: 'context7 connector api key env file reference library ids topic validate integrations knowledge.connectors' }, { tab: 'Knowledge', section: 'Knowledge Integrations', label: 'Context7 auto-import', key: 'knowledge.context7.auto_import', lazy: true, text: 'context7 auto import startup libraries knowledge.context7.auto_import[].id knowledge.context7.auto_import[].query' }, + { tab: 'Knowledge', section: 'Knowledge pipeline', label: 'Bead synthesizer, curator, code maps and primer', key: 'knowledge.bead_synthesizer.enabled', lazy: true, text: 'knowledge pipeline bead synthesizer wiki facts schedule hourly daily confidence target layer facts per cycle api calls vault retention archive beads high priority dependents curator auto promote threshold promotion from to code maps repository layer max bytes primer facts merge strategy engine public read-only mcp knowledge.engine knowledge.public.enabled knowledge.bead_synthesizer.enabled knowledge.bead_synthesizer.schedule knowledge.bead_synthesizer.min_confidence knowledge.bead_synthesizer.target_layer knowledge.bead_synthesizer.max_facts_per_cycle knowledge.bead_synthesizer.max_api_calls_per_cycle knowledge.bead_synthesizer.vault_path knowledge.bead_synthesizer.retention_policy.max_beads knowledge.bead_synthesizer.retention_policy.archive_after_synth_days knowledge.bead_synthesizer.retention_policy.high_priority_retain_days knowledge.bead_synthesizer.retention_policy.preserve_with_deps knowledge.curator.enabled knowledge.curator.schedule knowledge.curator.auto_promote_threshold knowledge.curator.promote_from knowledge.curator.promote_to knowledge.code_maps.enabled knowledge.code_maps.schedule knowledge.code_maps.layer knowledge.code_maps.max_bytes knowledge.code_maps.vault_path knowledge.primer.max_facts knowledge.primer.max_bytes knowledge.primer.merge_strategy' }, { tab: 'Forge App', section: 'Forge App', label: 'GitHub API quota, PR scans and authorship', key: 'github.api_reserve', lazy: true, text: 'github api quota reserve critical agent reserve floor pr detail cache ttl graphql batch page size app authored prs bot signed commits forge api url docs installation oauth client token github.api_reserve github.api_critical github.agent_reserve_floor github.pr_detail_ttl_s github.graphql_pr_batch github.graphql_pr_batch_page_size github.app_authored_prs github.app_signed_commits github.forge github.api_url github.docs_installation_id github.oauth_client_id github.token github.key_file' }, ]; let _settingsSearch = { active: false, query: '', previousTab: '', index: null, bound: false }; @@ -34050,7 +34051,7 @@

Configure LiteLLM

// (#9969, #11211); it loads from its own endpoint. if (tabId === 'Features') loadUpstreamWatch(); if (tabId === 'Appearance') loadThemeConfig(); - if (tabId === 'Knowledge') { renderPublicKnowledgeControls(); loadKnowledgeConnectors(); } + if (tabId === 'Knowledge') { renderPublicKnowledgeControls(); loadKnowledgeConnectors(); loadKnowledgePipeline(); } if (tabId === 'Compliance') loadComplianceStatus(); } // Keep the Save button's enabled state in lockstep with the repos/default @@ -34254,9 +34255,10 @@

Configure LiteLLM

function renderGovKnowledgeSharing() { return '
' + '

Knowledge sharing

' + - '

Control whether the read-only MCP endpoint can serve operational knowledge facts without dashboard credentials.

' + + '

Control whether the read-only MCP endpoint can serve operational knowledge facts without dashboard credentials. ' + yamlHint('knowledge.public.enabled') + ' Applies without a restart.

' + '
' + renderPublicKnowledgeControl('settings') + '
' + '
' + + renderKnowledgePipelineSection() + renderKnowledgeConnectorsSection(); } @@ -34402,6 +34404,217 @@

Configure LiteLLM

+ '
' + renderKnowledgeConnectorsBody() + '
'; } + // ---- Settings → Knowledge → Knowledge pipeline ---- + // The scalar knowledge.bead_synthesizer, curator, code_maps and primer + // keys, saved through PUT /api/config/knowledge/pipeline. The bead + // synthesizer switch applies at once; everything else is read at boot, so + // it applies after a restart. The engine, vault paths (directories the + // hive writes to) and primer merge strategy are display-only. + let _kpState = { loaded: false, error: '', data: {} }; + + async function loadKnowledgePipeline() { + try { + const r = await fetch('/api/config/knowledge/pipeline'); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status)); + _kpState = { loaded: true, error: '', data: data }; + } catch (e) { + _kpState = { loaded: true, error: e.message, data: {} }; + } + const el = document.getElementById('kp-pipeline-root'); + if (el) el.innerHTML = renderKnowledgePipelineBody(); + } + + function renderKnowledgePipelineSection() { + return '
' + + '

Knowledge pipeline

' + + '

How completed beads become wiki facts, how facts are promoted between layers, generated repository code maps, and the size of the knowledge primer each kick receives.

' + + '
' + renderKnowledgePipelineBody() + '
'; + } + + function renderKnowledgePipelineBody() { + if (!_kpState.loaded) return '

Loading knowledge pipeline settings…

'; + if (_kpState.error) return '

Failed to load knowledge pipeline settings: ' + esc(_kpState.error) + '

'; + const d = _kpState.data || {}; + const bs = d.bead_synthesizer || {}; + const rp = bs.retention_policy || {}; + const cu = d.curator || {}; + const cm = d.code_maps || {}; + const pr = d.primer || {}; + const def = d.defaults || {}; + const owner = dashboardRoleAtLeast(window._hiveRole || 'read', 'owner'); + const dis = owner ? '' : 'disabled'; + const restart = ' Requires restart.'; + const mark = (label, tip) => settingHelpMark('settings', 'knowledge', label, tip); + const num = (id, label, path, value, placeholder, step, max, help, tip) => ` +
+ + +
${help}${restart}
+
`; + const choice = (id, label, path, value, values, emptyLabel, help, tip) => ` +
+ + +
${help}${restart}
+
`; + const toggle = (id, label, path, on, help, live, tip) => ` +
+
+ ${esc(label)} ${yamlHint(path)} ${mark(label, tip)} — ${help}${live ? ' Applies without a restart.' : restart} +
`; + const roRow = (label, path, value, fallback) => `
${value ? '' + esc(String(value)) + '' : '' + esc(fallback) + ''} — set in hive.yaml; requires restart
`; + const schedules = d.schedules || ['hourly', 'daily']; + const layers = d.layers || ['personal', 'project', 'org', 'community']; + return ` +
+
Bead synthesizer
+ ${toggle('bs-enabled', 'Synthesize completed beads into wiki facts', 'knowledge.bead_synthesizer.enabled', bs.enabled, 'on by default; starts or stops the running synthesizer.', true, 'Turns completed beads (and their merged PRs) into wiki facts on a schedule. On unless set to false. Saving starts or stops the running synthesizer at once.')} +
+ ${choice('bs-schedule', 'Schedule', 'knowledge.bead_synthesizer.schedule', bs.schedule, schedules, 'Default (' + (def.bead_synthesizer_schedule || 'hourly') + ')', 'How often a synthesis cycle runs.', 'hourly or daily. Empty restores the default (hourly).')} + ${choice('bs-target_layer', 'Target layer', 'knowledge.bead_synthesizer.target_layer', bs.target_layer, layers, 'Default (' + (def.bead_synthesizer_target_layer || 'project') + ')', 'Layer the synthesizer vault is registered at with the primer.', 'The primer layer the synthesized facts are served from. Empty restores the default (project).')} + ${num('bs-min_confidence', 'Minimum confidence', 'knowledge.bead_synthesizer.min_confidence', bs.min_confidence, def.bead_synthesizer_min_confidence || 0.5, '0.05', '1', 'Facts classified below this confidence (0–1) are skipped.', 'Facts whose classification confidence falls below this are not written. Between 0 and 1; 0 restores the default (0.5).')} +
+
+ ${num('bs-max_facts_per_cycle', 'Max facts per cycle', 'knowledge.bead_synthesizer.max_facts_per_cycle', bs.max_facts_per_cycle, def.bead_synthesizer_max_facts_per_cycle || 20, '1', '', 'Cap on facts written per cycle after deduplication.', 'Cap on facts written per synthesis cycle after deduplication. 0 restores the default (20).')} + ${num('bs-max_api_calls_per_cycle', 'Max GitHub API calls per cycle', 'knowledge.bead_synthesizer.max_api_calls_per_cycle', bs.max_api_calls_per_cycle, def.bead_synthesizer_max_api_calls_per_cycle || 40, '1', '', 'Cap on charged PR GETs per cycle; HIVE_BEAD_SYNTH_MAX_API_CALLS still overrides.', 'Cap on charged GitHub PR GETs per synthesis cycle. PR reads are conditional, so a 304 reuses the cached PR and does not count. The HIVE_BEAD_SYNTH_MAX_API_CALLS environment variable overrides this. 0 restores the default (40).')} +
+ ${roRow('Vault path', 'knowledge.bead_synthesizer.vault_path', bs.vault_path, 'default ' + (def.bead_synthesizer_vault_path || ''))} +
Bead retention
+
+ ${num('rp-max_beads', 'Max beads', 'knowledge.bead_synthesizer.retention_policy.max_beads', rp.max_beads, 5000, '1', '', 'Archiving starts above this many beads.', 'The bead lifecycle manager archives the lowest-scored beads once a store holds more than this, down to 80% of it. 0 uses the default (5000).')} + ${num('rp-archive_after_synth_days', 'Archive after synthesis (days)', 'knowledge.bead_synthesizer.retention_policy.archive_after_synth_days', rp.archive_after_synth_days, 7, '1', '', 'Synthesized beads older than this become archive candidates.', 'Days after a bead has been synthesized into a fact before it may be archived. 0 uses the default (7).')} + ${num('rp-high_priority_retain_days', 'Keep high-priority beads (days)', 'knowledge.bead_synthesizer.retention_policy.high_priority_retain_days', rp.high_priority_retain_days, 30, '1', '', 'High-priority beads are kept at least this long.', 'Days a high-priority bead is kept before it may be archived. 0 uses the default (30).')} +
+ ${toggle('rp-preserve_with_deps', 'Never archive beads with open dependents', 'knowledge.bead_synthesizer.retention_policy.preserve_with_deps', rp.preserve_with_deps, 'on when the retention block is absent.', false, 'A bead that other open beads depend on is never archived. On when no retention_policy block is set.')} +
+
+
Curator (scheduled promotion)
+ ${toggle('cu-enabled', 'Promote facts on a schedule', 'knowledge.curator.enabled', cu.enabled, 'off by default; promotes verified facts with no human review.', false, 'Opt-in. On each run, verified facts in the source layer whose confidence reaches the threshold are copied into the target layer without human review. A schedule alone does not start it.')} +
+ ${choice('cu-schedule', 'Schedule', 'knowledge.curator.schedule', cu.schedule, schedules, 'Default (' + (def.curator_schedule || 'daily') + ')', 'How often the promotion sweep runs.', 'hourly or daily. Empty uses daily once the curator is enabled.')} + ${num('cu-auto_promote_threshold', 'Auto-promote threshold', 'knowledge.curator.auto_promote_threshold', cu.auto_promote_threshold, def.curator_auto_promote_threshold || 0.9, '0.05', '1', 'Minimum confidence (0–1) a verified fact needs to be promoted.', 'A verified fact is promoted only when its confidence is at least this. Between 0 and 1; 0 restores the default (0.9).')} +
+
+ ${choice('cu-promote_from', 'Promote from', 'knowledge.curator.promote_from', cu.promote_from, layers, 'Default (' + (def.curator_promote_from || 'project') + ')', 'Source layer for the sweep.', 'The layer facts are promoted from. Must differ from the target layer. Empty uses project.')} + ${choice('cu-promote_to', 'Promote to', 'knowledge.curator.promote_to', cu.promote_to, layers, 'Default (' + (def.curator_promote_to || 'org') + ')', 'Target layer for the sweep.', 'The layer promoted facts are copied into. Must differ from the source layer. Empty uses org.')} +
+
+
+
Repository code maps
+ ${toggle('cm-enabled', 'Generate repository code maps', 'knowledge.code_maps.enabled', cm.enabled, 'off by default; the repositories to map stay yaml-only.', false, 'Opt-in. Writes a generated, size-capped summary of each repository listed under knowledge.code_maps in hive.yaml and regenerates it when HEAD moves.')} +
+ ${choice('cm-schedule', 'Schedule', 'knowledge.code_maps.schedule', cm.schedule, schedules, 'Default (hourly)', 'How often HEAD is checked.', 'hourly or daily. Empty uses hourly.')} + ${choice('cm-layer', 'Layer', 'knowledge.code_maps.layer', cm.layer, layers, 'Default (project)', 'Knowledge layer recorded on each map.', 'The layer recorded on each generated map. Empty uses project.')} + ${num('cm-max_bytes', 'Max bytes per map', 'knowledge.code_maps.max_bytes', cm.max_bytes, 16384, '1', '', 'Cap on the rendered size of each map.', 'Cap on the rendered size of each map, in bytes. 0 uses the generator default (16384).')} +
+ ${roRow('Vault path', 'knowledge.code_maps.vault_path', cm.vault_path, 'default /data/vaults/code-maps')} +
+
+
Kick primer
+
+ ${num('pr-max_facts', 'Max facts', 'knowledge.primer.max_facts', pr.max_facts, def.primer_max_facts || 25, '1', '', 'Most facts placed in one kick primer.', 'The most knowledge facts placed in one kick primer. 0 restores the default (25).')} + ${num('pr-max_bytes', 'Max bytes', 'knowledge.primer.max_bytes', pr.max_bytes, def.primer_max_bytes || 4096, '1', '', 'Cap on the rendered primer; facts past it are dropped whole.', 'Cap on the rendered knowledge primer per kick. Facts past it are dropped whole and a marker says so. 0 restores the default (4096).')} +
+ ${roRow('Merge strategy', 'knowledge.primer.merge_strategy', pr.merge_strategy, 'default ' + (def.primer_merge_strategy || 'precedence'))} + ${roRow('Engine', 'knowledge.engine', d.engine, 'unset')} + ${owner ? '' : '
Owner role required to change these settings.
'} +
`; + } + + function kpToggle(el) { + if (!el || el.classList.contains('disabled')) return; + const on = el.classList.toggle('on'); + el.setAttribute('aria-checked', on ? 'true' : 'false'); + } + + async function saveKnowledgePipeline() { + const d = _kpState.data || {}; + const cur = { + bead_synthesizer: d.bead_synthesizer || {}, + retention_policy: (d.bead_synthesizer || {}).retention_policy || {}, + curator: d.curator || {}, + code_maps: d.code_maps || {}, + primer: d.primer || {} + }; + const fields = [ + ['bead_synthesizer', 'enabled', 'bs-enabled', 'bool'], + ['bead_synthesizer', 'schedule', 'bs-schedule', 'str'], + ['bead_synthesizer', 'target_layer', 'bs-target_layer', 'str'], + ['bead_synthesizer', 'min_confidence', 'bs-min_confidence', 'frac'], + ['bead_synthesizer', 'max_facts_per_cycle', 'bs-max_facts_per_cycle', 'int'], + ['bead_synthesizer', 'max_api_calls_per_cycle', 'bs-max_api_calls_per_cycle', 'int'], + ['retention_policy', 'max_beads', 'rp-max_beads', 'int'], + ['retention_policy', 'archive_after_synth_days', 'rp-archive_after_synth_days', 'int'], + ['retention_policy', 'high_priority_retain_days', 'rp-high_priority_retain_days', 'int'], + ['retention_policy', 'preserve_with_deps', 'rp-preserve_with_deps', 'bool'], + ['curator', 'enabled', 'cu-enabled', 'bool'], + ['curator', 'schedule', 'cu-schedule', 'str'], + ['curator', 'auto_promote_threshold', 'cu-auto_promote_threshold', 'frac'], + ['curator', 'promote_from', 'cu-promote_from', 'str'], + ['curator', 'promote_to', 'cu-promote_to', 'str'], + ['code_maps', 'enabled', 'cm-enabled', 'bool'], + ['code_maps', 'schedule', 'cm-schedule', 'str'], + ['code_maps', 'layer', 'cm-layer', 'str'], + ['code_maps', 'max_bytes', 'cm-max_bytes', 'int'], + ['primer', 'max_facts', 'pr-max_facts', 'int'], + ['primer', 'max_bytes', 'pr-max_bytes', 'int'] + ]; + const body = {}; + const put = (group, key, v) => { + if (group === 'retention_policy') { + body.bead_synthesizer = body.bead_synthesizer || {}; + body.bead_synthesizer.retention_policy = body.bead_synthesizer.retention_policy || {}; + body.bead_synthesizer.retention_policy[key] = v; + } else { + body[group] = body[group] || {}; + body[group][key] = v; + } + }; + for (const [group, key, id, kind] of fields) { + const el = document.getElementById('kp-' + id); + if (!el) continue; + const was = cur[group][key]; + if (kind === 'bool') { + const on = el.classList.contains('on'); + if (on !== !!was) put(group, key, on); + } else if (kind === 'str') { + const v = (el.value || '').trim(); + if (v !== (was || '')) put(group, key, v); + } else { + const raw = (el.value || '').trim(); + const v = raw === '' ? 0 : (kind === 'frac' ? parseFloat(raw) : parseInt(raw, 10)); + if (isNaN(v) || v < 0 || (kind === 'frac' && v > 1)) { showToast(key + (kind === 'frac' ? ' must be between 0 and 1' : ' must be a non-negative number'), 'error'); return; } + if (v !== (was || 0)) put(group, key, v); + } + } + if (!Object.keys(body).length) { showToast('Nothing to save', 'error'); return; } + const btn = document.getElementById('kp-save-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; } + try { + const res = await fetch('/api/config/knowledge/pipeline', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status)); + _kpState = { loaded: true, error: '', data: data }; + const bsKeys = Object.keys(body.bead_synthesizer || {}); + const liveOnly = Object.keys(body).length === 1 && bsKeys.length === 1 && bsKeys[0] === 'enabled'; + showToast(liveOnly ? 'Knowledge settings saved' : 'Knowledge settings saved — restart the hive to apply them', 'success'); + const el = document.getElementById('kp-pipeline-root'); + if (el) el.innerHTML = renderKnowledgePipelineBody(); + } catch (e) { + showToast('Save failed: ' + e.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Save'; } + } + } + async function kcSave(list, extra) { const r = await fetch('/api/config/knowledge/connectors', { method: 'PUT',