The Discovery tab stated the same updated_at timestamp twice: once in the
'Discovery run / Last run: X ago' control block and again as 'Last observed
X ago' on the identified-service card (and 'Last scanned: X ago' on the
not-identified state). Since every drawer that renders DiscoveryTab passes
showManualRunAction, the control block is always present, making the other two
pure duplicates.
Gate the service-card 'Last observed' and the not-identified 'Last scanned'
timestamps on !showManualRunAction(), so recency appears once — in the control
block when it's rendered, in place otherwise. The provenance attribution
('Observed by Discovery', 'Available to Pulse Assistant') and the cross-tab
drawer-header freshness banner are unchanged. Regression test added; verified
live (grafana drawer now shows 'Last run' once, no duplicate 'Last observed').
The metadata-only abstention is only reached when command scanning is already
enabled but the host agent returned no command output. The old reason text told
the user to 'Enable Pulse Commands' — a toggle that is provably already on when
this message appears — so the guidance was self-contradictory. The reason now
states commands are enabled and points at the actual gap: confirm the host
agent is connected and its API token has the agent:exec scope.
The Proxmox overview's shared search box is a VM/LXC filter, but filteredNodes
applied the same term to the nodes table independently. Searching a guest name
(e.g. 'debian-go') matched no node name, so the nodes table collapsed to the
'No Proxmox VE nodes' empty state — which reads as 'you have no Proxmox
infrastructure' even though the matching guest was listed right below it on its
host node.
Extract filterProxmoxNodesForSearch: keep a node when it matches the term
directly OR when it hosts a guest that matches the term. A guest search now
keeps that guest's host node visible for context; a node-name search still
narrows to the matching node; an empty term still shows every node. Regression
tests cover all three cases; verified live (searching 'debian-go' now shows the
minipc host node instead of the empty state).
When discovery has no in-guest command evidence, the backend abstains (empty
service, confidence 0) rather than confabulate a service, and explains why in
ai_reasoning (e.g. 'Discovery could not run commands on this resource... Enable
Pulse Commands (Settings → Infrastructure)...'). The Discovery tab discarded
that explanation: hasMeaningfulDiscoveryContext() ignores ai_reasoning, so an
abstention failed the render gate and fell into the generic 'Unknown Service /
Discovery completed but couldnt identify a known service' state with no reason
and no next step. After a 'Discovery complete' run, the user saw a dead-end.
The not-identified branch now surfaces ai_reasoning (the actionable
explanation) and a clickable 'Open Settings → Infrastructure' link when command
execution isn't confirmed enabled, with a neutral 'Service not identified'
heading. Identified guests are unaffected (they render via the valid-discovery
block). Regression test added; verified live against a mock guest that abstains.
The install-time auto-register path (auto_register_pve_node) parsed the API
token secret out of pveum's box-drawing table output with a fragile awk
column-split. The web-setup render path was already hardened to request
'pveum ... --output-format json' first and parse the value field, but this
secondary install.sh path was never ported.
auto_register_pve_node now requests --output-format json first (falling back
to the bare --privsep 1 form only when an older pveum rejects the JSON flag,
which keeps the secure-installer contract pin on that form satisfied) and
extracts the secret via a new extract_pve_token_value helper: JSON value-field
parse first, then a locale-independent box-drawing table fallback (normalizes
the column separator to a plain pipe byte-wise before splitting, so it works
regardless of host locale). This mirrors the hardened render path and removes
the silent-failure / mis-parse risk when pveum table formatting drifts.
Functional + contract tests in root_install_sh_test.go; deployment-installability
contract documents the deterministic extraction. The host-agent path
(internal/hostagent/proxmox_setup.go setupPVEToken) carries an agent-lifecycle
token-permission proof obligation and is left for a governed lane.
A Proxmox host upgraded from v5 may still carry the legacy pulse-sensor-proxy
footprint (binary, systemd units, runtime/state dirs, dedicated service user,
and managed SSH keys in root's authorized_keys). install.sh --uninstall removed
everything for the Pulse server itself but left that legacy footprint behind,
so a 'complete uninstall' was not complete -- most notably it left SSH key
entries in /root/.ssh/authorized_keys.
uninstall_pulse now calls cleanup_local_sensor_proxy, which removes the LOCAL
footprint only: stop/disable the units, remove the binary/units/runtime/state
dirs, strip the '# pulse-managed-key' / '# pulse-proxy-key' entries, and remove
the service user/group. It is presence-gated (silent no-op when no proxy was
installed). The aggressive cluster-wide authorized_keys removal and
pulse-monitor@pam API-user deletion stay behind the explicit standalone
scripts/uninstall-sensor-proxy.sh, which we print a pointer to.
Functional + contract tests in scripts/installtests/root_install_sh_test.go;
deployment-installability contract documents the new uninstall removal scope.
Finishes the deferred half of the v5->v6 parity fix for #1323 (the
pulse-auto-update.sh half landed in 672e81985). The interactive install.sh
update/reinstall flow stopped a running Pulse then called start_pulse, which
tolerates a silent start failure (common on unprivileged LXC) by printing a note
and returning 0 — leaving Pulse stopped under an "installation completed!" message.
- stop_pulse_for_update records whether Pulse was running before the update.
- start_pulse, only when Pulse was running before (PULSE_WAS_ACTIVE), no longer
accepts a silent start failure: it verifies the service became active
(wait_for_service_active, 20s), retries one explicit start, and surfaces a clear
error + diagnostics if it still will not come up. Fresh installs are unchanged
(the flag stays false, so the reassuring container note is kept).
- Wired into all three update/reinstall sites; added a BASH_SOURCE guard so the
installer's functions can be unit-tested without running the installer.
Scope: fixes the #1323 'restart silently failed' case. Does NOT add a binary
rollback (download_pulse deletes bin/pulse.old right after the swap) — a
bad-release rollback is a separate concern.
Test: scripts/tests/test-install-update-resilience.sh (sources install.sh, stubs
systemctl, asserts was-active capture + retry + clear error). Go installtests +
bash -n confirm the guard does not change installer execution.
Discovery records for Proxmox guests (VM / system-container) are canonically
keyed by node name + VMID — the form the background fingerprint loop and the
Assistant prefetch already use. But the browser action path addresses guests by
the linked agent UUID (discoveryTarget.agentId, the action-authorization target),
and normalizeDiscoveryRequest was a no-op for guest types — so a UUID-targeted
trigger/lookup stored and read records under a second, divergent key. Result: the
resource drawer reported "Not discovered" for a guest the background loop had
already discovered, and duplicate records accumulated (node-keyed + UUID-keyed).
normalizeDiscoveryRequest now canonicalizes guest targets to the node name:
resolve a linked-agent-UUID target back to its hosting node (node->linked-agent
map, falling back to the agent host's hostname when it is a known node). Both
creation (DiscoverResource) and lookup (GetDiscoveryByResource) funnel through
this chokepoint, so every path converges on the node-name key; the caller already
aliases the original target, so pre-existing UUID-keyed records are still found
and consolidated onto the canonical key on the next run.
The agent UUID is unchanged as the action-authorization target — this governs
only the record key, so the discoveryTarget security contract is untouched.
- Test: TestService_ProxmoxGuestDiscoveryCanonicalizesToNodeKey.
- Live-verified on the dev instance: the Home Assistant LXC drawer readiness went
missing -> fresh (discoveryId system-container:delly:101).
Extend the cloud-context e2e test to stamp the discovery with an UpdatedAt and
assert "Last discovered: 2 days ago" reaches the model on both the handoff and
@-mention paths — proving freshness survives the full ExecuteStream path (cloud
policy, sanitizer allow-list, prompt injection), not just the formatter unit.
Test-only.
formatSingleDiscovery (the local/full path, FormatForAIContext) reported a
resource's service, access, paths, and ports but not how old the discovery was —
so Ollama/local users lacked the recency signal cloud users just got. Emit a
"Last Discovered: <age>" header fact when the timestamp is known, completing
freshness parity across the cloud-safe and full paths. Omitted when unknown.
Test: TestFormatSingleDiscovery_IncludesFreshness (present when set, absent when
zero).
The model now receives provenance — per-fact source/confidence and discovery
freshness — but nothing told it to use it, so answers stated facts the user
couldn't trace or weigh. Add a GROUNDING & PROVENANCE section to the base system
prompt: briefly attribute facts to their source ("Debian 12, per
/etc/os-release"), note recency for time-sensitive claims, do not present stale
context as current, and keep attribution concise rather than citing every line.
This is the prose-attribution layer of the provenance work (the visible part
chosen as the first, lowest-risk step over a heavier citations UI). Whether the
model attributes well in practice is a live-behavior question to verify by
exercising the Assistant — the prompt only guarantees the instruction reaches it.
- Test: TestBuildSystemPrompt_IncludesProvenanceGuidance.
- Contract: ai-runtime base prompt must instruct provenance attribution.
The pushed cloud-safe operational context told the model a resource's access
pattern, paths, and ports but not how OLD the discovery was — so the Assistant
could present a 2-week-old cached scan as current. For a monitoring assistant,
recency is the most important provenance signal.
FormatCloudSafeContext now appends "Last discovered: <age>" (via the existing
FormatDiscoveryAge helper, previously unused) when the timestamp is known, and
the push-path conversion (cloudSafeOperationalContext) carries UpdatedAt through.
A timestamp is non-identifying, so it adds no PII. Omitted when unknown.
- Tests: FormatCloudSafeContext freshness present/absent; cloudSafeOperationalContext
carries UpdatedAt end of the push conversion (and still emits no PII).
- Contract: ai-runtime cloud-safe context must carry discovery age when known.
Follow-up: same freshness line on the local/full path (formatSingleDiscovery).
buildDiscoveryToolResponse rebuilt each fact as {category,key,value} and dropped
the Source (the command/origin that produced it) and Confidence the DiscoveryFact
already carries — so the model could state facts it couldn't attribute or weight.
Serialize both per fact (omitted when empty) so the Assistant can report "Debian
12, per /etc/os-release" instead of a bare, untraceable claim.
This is the first piece of the provenance/trust groundwork: make the model
*receive* the source metadata it needs to cite. Surfacing it to the user (UI) and
push-path discovery freshness are follow-ups.
- Test: TestBuildDiscoveryToolResponse_IncludesFactProvenance (source+confidence
present when set, omitted when empty).
- Contract: ai-runtime section 8 documents the pulse_discovery fact provenance.
The drawer "Ask Assistant" handoff anchored a resource but never delivered its
operational context (access command, config/data/log paths, ports) to the model
— that only reached the model via @-mentions. Route handoff resources through
the same prefetch path so the proactive path matches the @-mention path on cloud
turns; PII (hostname/IP/alias) stays redacted at the model boundary.
- Reconcile the handoff Data Boundary directive: the model may use the PII-free
operational context to answer and guide the user, while still never revealing
raw hostnames/IPs/aliases/secrets. Action Boundary unchanged (read-only; any
mutation goes through the governed approval flow).
- Fix a pre-existing clobber: the plain-text resource resolver overwrote the
prefetch summary on the @-mention path, dropping the operational context
before it reached the model. Run it only as a fallback when no structured
mention resolved.
- Add the first end-to-end test asserting the operational context reaches a
cloud model on BOTH the handoff and @-mention paths while PII is redacted.
- Update the ai-runtime contract to reflect the reconciled handoff data boundary,
handoff operational-context parity, and the plain-text fallback ordering.
Per maintainer decision: the cloud-context-privacy feature was bloat. The real
fix for the "useless Assistant on cloud" problem was the earlier sensitivity
recalibration (ordinary workloads = Internal, not redacted); the dial layered a
configurable knob on top of an already-solved problem, guarding mostly-non-secret
data on a destination the operator opted into, and demanded every model-bound path
stay dial-aware (a standing leak surface). The privacy control users actually
understand is the choice of model — cloud provider vs. local Ollama.
Removed entirely:
- AIConfig.CloudContextPrivacy dial + constants + GetCloudContextPrivacy /
NormalizeCloudContextPrivacy, AND the now-dead legacy
ShareOperationalContextWithCloud boolean + ShouldShareOperationalContextWithCloud
(internal/config/ai.go); the config-load migration (persistence.go).
- Both fields from the /api/settings/ai request/response, validation, and sync
(ai_handlers.go) + the JSON contract snapshots.
- The "Cloud model privacy" 3-option UI control, form field, presentation copy,
and CloudContextPrivacy type (frontend), plus their tests.
- The dial branching in the seam: chat/service.go cloudPrivacyLevel,
CloudContextPolicy.Level + local_only suppression + the localOnly directive
(context_prefetch.go), the inventory resourceLabel dial logic (resource_context*),
and the modelboundary RedactLocalOnlyResourcesOnly option.
Fixed lean posture (no setting): a cloud-routed model receives real infrastructure
context, with two always-on invariants enforced by the model-boundary sanitizer —
credentials are always stripped, and local-only/Restricted resources (the floor)
never leave the local trust boundary. Local (Ollama) always full. The sanitizer's
default is now the local-only floor; it remains the universal backstop installed on
EVERY model-bound path (chat, session compaction, discovery/report/analysis via the
shared helper). Kept the two standalone fixes from this effort: compaction now
routes through the sanitizer, and directives no longer inject the "redacted by
policy" placeholder.
Governance: ai-runtime contract rewritten to a fixed-posture rule; api-contracts /
frontend-primitives / agent-lifecycle / storage-recovery dial references removed.
Tests updated to the floor-only behavior (local-only redacted, Sensitive flows,
secrets stripped). Full internal/ai/..., config, api suites green; frontend
type-check + tests + lint green.
While investigating a "bazarr → Unknown Service" discovery report, found that
increment 2 only wired the dial into the interactive chat seam. The shared helper
(*Service).requestSanitizerForModel — used by discovery analysis, the report and
fleet narrators, quick analysis, and the ExecuteAgentic paths — always installed a
FULL-redaction sanitizer regardless of the dial. So at the "full" dial those paths
silently over-redacted governed resources: e.g. discovery could not identify a
governed service even though the operator chose full.
(Note: bazarr itself is classified Internal/cloud-summary, so it is NOT redacted —
its "Unknown Service" is a discovery service-identification matter, not redaction.
This fix addresses the governed-resource case the same gap would break.)
Fix: requestSanitizerForModel now resolves the dial from the config snapshot
(fail-closed to redacted when absent) and passes RedactLocalOnlyResourcesOnly() at
"full", exactly like the chat seam. Local (Ollama) still gets no sanitizer. The
local-only hard floor still protects must-not-leave resources at full.
Proof: TestRequestSanitizerForModel_HonorsCloudPrivacyDial — at full a Sensitive
(local-first) identifier flows while a Restricted (local-only) one stays redacted
and the bearer token is always stripped; at redacted both identifiers are redacted;
local model gets a nil sanitizer. Contract: ai-runtime universal backstop rule now
states the shared helper must honor the dial too (functional parity), not just
install the sanitizer. Full internal/ai/... suite green (23 packages).
A systematic audit of all model-bound paths (not just the ones touched reactively)
found a leak: SummarizeSession (internal/ai/chat/session_compaction.go) sent the
PERSISTED chat transcript to the chat model via provider.Chat WITHOUT the
dial-aware model-boundary sanitizer. The transcript is built from persisted
messages — original user prompts and tool outputs — which carry raw resource
identifiers (hostnames/IPs/names) regardless of how the live turns were redacted.
On a cloud chat model at redacted/local_only, that shipped identifiers to the
provider, contradicting the cloud_context_privacy dial. (Secrets were already
redacted at transcript-build time via safety; resource identifiers were not.)
Fix: run the compaction ChatRequest through modelboundary.RequestSanitizerForModel
with the same dial resolution as a normal turn — fail closed to redacted when no
config snapshot, RedactLocalOnlyResourcesOnly() at full, nil (no-op) for local
Ollama. Mirrors the interactive seam exactly.
Audit also checked: report/fleet narrators, quick analysis, ExecuteAgentic(Stream),
discovery analysis, and Patrol all already install the sanitizer (verified). The
Patrol preflight self-test sends a fixed payload with no resource content, so it
needs no sanitizer (verified static, not trusted from the audit summary). agentcontext
output flows through the sanitized agentic loop. So compaction was the one gap.
Contract: ai-runtime gains a UNIVERSAL backstop rule — every model-bound path that
carries infrastructure-derived content must install the dial-aware sanitizer;
session compaction named explicitly; static no-identifier probes exempted. Proof:
TestServiceSummarizeSessionRedactsResourceIdentifiersForCloud (a transcript
hostname is stripped in the captured compaction request at the redacted dial);
existing compaction tests stay green (fix is additive — empty-model path unchanged).
Full internal/ai/... suite green (23 packages).
Increment 2 wired the dial into the prefetch and the model-boundary sanitizer but
missed a third model-bound path: the broad inventory context builder
(internal/ai/resource_context.go buildUnifiedResourceContextForModel) rendered
resource display names through unifiedresources.ResourcePolicyLabel, which redacts
genuinely-sensitive names UNCONDITIONALLY — ignoring the dial and even local-vs-cloud.
Symptom (reported live): on a cloud model the Assistant surfaced "redacted by
policy" and tried to run pulse_query with it as a search term, because sensitive
resources appeared redacted in the inventory regardless of the dial.
Fix: unifiedResourcePolicyContext now carries the dial + a known-local flag, and a
new resourceLabel() renders names per the dial — known-local (Ollama) always real;
cloud real only at "full" and only for resources NOT routed local-only (the same
hard floor as the sanitizer); unknown/empty destination fails closed to the
governed label (preserves the safe default for the no-destination context path).
All 15 ResourcePolicyLabel call sites in the inventory builder route through it.
This also fixes a latent inconsistency where local (Ollama) models over-redacted
inventory names despite "local is always full".
Proof: TestUnifiedResourcePolicyContext_ResourceLabelDialAware (local real;
cloud-full sensitive real, local-only floored; cloud-redacted both governed). The
existing AI-safe-summary inventory tests (no-destination path) stay green via the
fail-closed unknown-destination branch. Governance: ai-runtime contract delta —
the seam is now THREE dial-aware paths, inventory builder included. Full
internal/ai/... suite green (23 packages).
The resource-context handoff directives in internal/ai/chat/service.go and
internal/ai/chat/plain_text_resource_context.go named the literal redaction
placeholder ("Do not copy 'redacted by policy' into any tool argument", "labels
may be redacted by policy"). Naming the phrase in the prompt made the model echo
it back to the user as if it were the resource name — the confusing leakage
flagged after the sensitivity recalibration.
Reword the directives neutrally ("a withheld or placeholder label", "some labels
may be withheld") and add an explicit instruction not to repeat a withheld
placeholder back as the resource identity. The current_resource handle remains
the authoritative target, and the tool resolver still accepts the placeholder as
a defensive alias, so behavior is unchanged — only the prompt wording.
Proof: the plain-text resource-context test now pins that the directive does NOT
inject the redaction placeholder (previously it asserted the opposite, which was
pinning the leakage). Governance: substantive ai-runtime contract delta adding a
redaction-placeholder-hygiene rule for Pulse-authored model-bound directives.
Full internal/ai/... suite green.
The dial now drives model-bound redaction directly, replacing the legacy
ShareOperationalContextWithCloud read. internal/ai/chat/service.go resolves
cloudPrivacyLevel once per turn (failing closed to "redacted" when no config
snapshot is present) and threads it into the prefetch and the model boundary:
- full: the model-bound resource-policy sanitizer is invoked with
modelboundary.RedactLocalOnlyResourcesOnly(), so real identifiers (hostname,
IP, alias, name) for ordinary (Internal) and Sensitive (local-first) resources
reach the cloud model — the core "answer with real detail" win. Resources the
policy engine routes local-only (Restricted) stay redacted as a HARD FLOOR a
blanket dial must never override, so default-full never ships a must-not-leave
resource to a cloud vendor. Prompt-secret sanitation (credentials) always runs.
- redacted: every policied resource's identifiers are redacted as before, and the
prefetch surfaces the PII-free operational context for governed resources.
- local_only: the prefetch injects NO proactive infrastructure context to the
cloud turn (only a transparency directive pointing at the setting / a local
model), and the sanitizer still redacts identifiers as a backstop.
CloudContextPolicy now carries the dial Level (failing closed to redacted for
empty/unknown) instead of a ShareOperationalContext bool; sharesCloudOperationalContext
covers full+redacted, suppressesCloudContext covers local_only. The obsolete
"Share operational context with cloud models" transparency string is replaced by
a local_only directive referencing "Cloud model privacy".
modelboundary gains RedactLocalOnlyResourcesOnly() + localOnlyRoutedResources();
the resource-redaction pass narrows to the local-only floor at full while
prompt-secret sanitation is unconditional. Local (Ollama) models never reach the
sanitizer.
Governance: substantive ai-runtime contract delta describing the dial-driven seam
and the local-only floor. Proofs: modelboundary sanitizer tests (full keeps the
floor + redacts secrets; default redacts all identifiers), CloudContextPolicy
level semantics + prefetch full/redacted/local_only behavior, and the handoff
relationship test pinned to redacted. Full internal/ai/..., unifiedresources,
and agentcontext suites green.
Introduce the single privacy dial that will govern what infrastructure context
cloud models may see, replacing the binary share_operational_context_with_cloud
toggle as the canonical operator control. This increment adds and surfaces the
setting; it does not change the redaction seam (that is increment 2).
Config (internal/config/ai.go): add AIConfig.CloudContextPrivacy with the
full|redacted|local_only levels, default "full", plus NormalizeCloudContextPrivacy
and the nil-safe GetCloudContextPrivacy getter. NewDefaultAIConfig defaults a fresh
self-hosted install to "full" so the Assistant answers with real resource detail
out of the box. The legacy ShareOperationalContextWithCloud boolean is retained as
the field the redaction seam still reads until it is wired into the dial directly.
Migration (internal/config/persistence.go): LoadAIConfig derives the dial from the
legacy toggle for pre-dial configs (legacy on -> full, off/absent -> redacted) and
persists it, leaving the legacy boolean untouched so existing installs keep their
current cloud behavior byte-for-byte. Fresh installs (no config file) default to full.
API (internal/api/ai_handlers.go): round-trip cloud_context_privacy through
/api/settings/ai field-by-field like discovery_enabled. The response always
serializes GetCloudContextPrivacy() (no omitempty) so the UI binds a 3-option
control to the concrete value; the update request carries an optional *string
validated against NormalizeCloudContextPrivacy (unknown values -> 400). When the
dial is provided it supersedes and re-syncs the legacy boolean (full -> true,
redacted/local_only -> false) so the existing seam honors the dial's full/redacted
axis without new redaction code paths.
Frontend: replace the binary "Share operational context with cloud models" toggle
with a "Cloud model privacy" 3-option FormSelect in AIRuntimeControlsSection.tsx,
bound to state.form.cloudContextPrivacy and the cloud_context_privacy payload via
useAISettingsState. CloudContextPrivacy type + payload fields in types/ai.ts;
label/help/option/summary copy in aiSettingsPresentation.ts.
Governance (ai-runtime + frontend-primitives substantive deltas; dependent
api-contracts, agent-lifecycle, storage-recovery notes): the contracts now name the
dial as canonical with the legacy boolean as the synced/migrated seam field.
Proofs: ai_config_test.go (getter/normalize/default), persistence_ai_test.go
(migration cases), ai_handlers_test.go (round-trip + legacy sync + 400),
contract_test.go JSON snapshots, settingsArchitecture + aiSettingsPresentation tests.
Live-verified in the preview drawer: dial renders with all three levels, the
migrated value (redacted) is selected, the summary updates reactively, and an
end-to-end UI save round-trips full (legacy sync true) then restores redacted.
In the trace for 'hows esphome', the model called get with the canonical handle
'system-container-599a2e3...' as resource_id and no resource_type, and it failed
twice with 'resource_type is required' before recovering with 'get 102'. The user
sees those failed tool calls in the chat.
A canonical handle already encodes the type (unifiedresources/ids.go builds ids
as '<type>-<hash>'), so executeGetResource now infers resource_type from the
handle when it's omitted, via resourceTypeFromCanonicalID (the trailing hex hash
segment is unambiguous since no type word is all-hex). A bare numeric VMID still
requires an explicit type. Test TestResourceTypeFromCanonicalID covers it; full
internal/ai/tools green.
User asked 'hows esphome' and the Assistant replied it had 'no infrastructure
context or diagnostic tools available' and asked them to run docker ps and say
where esphome lives — for a container (CT 102 esphome on delly) Pulse already
inventories.
Root cause: assistantPromptLooksConversational treated any prompt with <= 3 words
as chit-chat, routing it to the text-only scope that offers ZERO tools. So every
natural short lookup ('hows esphome', 'check frigate', 'grafana cpu') had its
tools withheld and the model genuinely couldn't query. This is the prompt-keyword
router anti-pattern the contract forbids.
Remove the word-count rule: only explicit greeting/meta prompts (hi, thanks, who
are you) are conversational; everything else is offered tools and the model
decides whether to use them. Verified live: 'hows esphome' now returns its real
status (Online, CPU 4.2%, mem 8.4%, no alerts) from Pulse data.
Regression test TestToolsForAssistantTurn_ShortResourceLookupGetsTools asserts
short lookups get tools while greetings stay text-only. Full internal/ai/chat green.
User on a cloud model saw 'redacted by policy' everywhere. Root cause: the
default classification (classifyResourceSensitivity) treated every VM, container,
pod, k8s workload, and docker service as 'Sensitive', which redacts their
hostname/IP/alias/path for cloud models. For Pulse's homelab/SMB audience that
crippled the cloud Assistant — a workload named 'grafana' isn't a secret, and its
private LAN IP isn't either.
Recalibrate: compute workloads classify as 'Internal' (cloud-summary, no
redaction) so cloud models can see their names/IPs. Escalation to
Sensitive/Restricted is by tag (database, backup, customer-data, secret, ...) or
by genuinely sensitive TYPE: storage/data-at-rest (storage, PBS, Ceph,
physical-disk, network-share, network, k8s PV/PVC/StorageClass), configuration
(docker-config, k8s-configmap), and security (k8s RBAC, secrets, PMG). Secrets
and PMG stay Restricted; the tag-based escalation is unchanged.
Tests: new TestRefreshPolicyMetadata_PlainComputeWorkloadsAreInternalNotRedacted
+ TestComputeWorkloadPolicyIsInternalUnlessEscalated lock it in. ~13 AI-subsystem
redaction tests that assumed plain compute = Sensitive updated to tag their
fixtures so they still exercise redaction on a genuinely-sensitive resource (no
assertions weakened). Contract: unified-resources Extension Points documents the
recalibrated classification. internal/ai/... + internal/unifiedresources/... green.
When the model runs tools but returns no final narrative, Pulse synthesized a
fallback summary. It leaked the provider call ids as the 'tool names'
(normalizeToolUseID returns call_27f0f389… unchanged because the hex suffix
isn't all digits) and appended a raw JSON tool-output snippet — so the chat
showed 'I completed 4 successful check(s) using call_27f0f389…, … automatic
summary. Latest successful result snippet: {"systems":[]…}'. Far more
un-OpenCode than anything in the transcript.
buildAutomaticFallbackSummary now resolves each tool result's provider call id to
the real tool name from the assistant tool call (pulse_ prefix stripped), drops
opaque call_/toolu_/fc_ ids entirely, removes the raw-output snippet, and reads
as a clean operator message: 'I ran N checks (query, metrics) but the model
didn't return a written summary this time. Ask me again and I'll pull the
results together.'
New regression test reproduces the real OpenRouter shape (call_ ids on results,
real names on the tool calls) and asserts no call_ ids, real tool names, no raw
JSON. Full internal/ai/chat suite green.
User: the chat populates too much during a turn vs OpenCode. Read OpenCode's TUI
(packages/tui/src/routes/session/index.tsx): live 'working' state lives in ONE
pinned footer line (spinner + interrupt), and the scrolling transcript holds only
durable artifacts (user text, reasoning, tool calls, the answer). It never
narrates 'Preparing context / Reading inventory / Counting' into the timeline.
Pulse was doing both — workflow status rendered as transcript rows AND a header
chip AND (briefly) in the activity dock. Mirror OpenCode:
- MessageItem no longer renders workflow status in the transcript: the per-event
role=status row (shouldRenderWorkflowStatusEvent) and the early-phase header
chip (shouldShowHeaderWorkflowStatus) are both hard-false.
- The activity dock is now the single live indicator and persists for the whole
turn: gate it on the streaming assistant message (assistantTurnActive) instead
of chat.isLoading(), which flips false at visible-turn-complete and made the
dock flash its status for a frame then vanish.
Result (verified live): transcript = user msg -> model route -> compact tool
rows -> answer; live status (spinner + 'Model is reasoning...' + route + Stop)
stays pinned in the footer while working, gone when done.
9 MessageItem tests that asserted the old transcript-row behavior rewritten to
assert footer-owned suppression; pacing/retry coverage stays in activeTurnStatus.
840 chat tests green (1 pre-existing ModelSelector failure, unrelated). Contract:
canonical footer-only rule added, supersedes the per-row transcript-status rules.
Final piece of the streaming-jank work. AssistantMarkdownBlock rendered
renderMarkdown(text) into innerHTML on every paced reveal, which rebuilds the
entire prose subtree — so a multi-paragraph or list/table answer flickers and
reflows every earlier line as it streams. Measured live: a 5-item numbered list
produced ~232 DOM mutations / 106 list-item removals over one turn.
Add markdownMorph.ts: reconcile old and new trees in place — identical nodes
untouched, same-tag nodes morphed (recursing into children so a growing
<ol>/<table> keeps its earlier <li>/<tr>), growing tail block updates its text
node rather than rebuilding. AssistantMarkdownBlock now feeds the sanitized HTML
to it via a ref+effect.
Same list answer after: ~22 mutations / 1 list-item removal — a ~10x drop. The
earlier lines stay put; only new items append and the tail updates in place.
Security unchanged: renderMarkdown (DOMPurify) remains the sole sanitization
gate; the morph only reconciles already-sanitized nodes. 9 morph unit tests +
286 MessageItem/AIChat tests green; tsc clean; render verified correct live.
Follow-up to the message-level reconcile fix. One level down, the per-message
stream-event/tool-row list in MessageItem rendered groupStreamEventsForDisplay()
through a reference-keyed <For>. That memo remaps blocks to fresh objects every
tick, so the streaming answer block (and completed tool rows) re-mounted — and
re-parsed markdown — on every content delta.
StreamDisplayEvent has no id, but the grouped list is strictly append-ordered
(the grouper only pushes new blocks or mutates the open content/thinking block in
place; never inserts mid-list or reorders), so positional keying is correct.
Switch the inner list to <Index>: each row keeps its DOM node across event-object
rebuilds at a stable position and updates in place.
Verified: <Index> reuses DOM nodes per position (probe); regression test proven
to fail on the original <For> (row re-created) and pass with <Index>; 286
MessageItem+AIChat tests green; tsc clean (it flagged every evt -> evt() spot).
The chat felt janky during a turn — status rows popping in and out, the answer
flashing, the transcript jumping up and down — unlike OpenCode's stable timeline.
Root cause (measured with a live DOM mutation observer): useChat rebuilds its
message array immutably on every stream event, spreading a brand-new message
object each time. ChatMessages rendered that array through <For>, which keys by
object reference, so the whole MessageItem was torn down and recreated on every
content chunk / workflow-status change / tool update — dozens of re-mounts per
turn (observer showed the assistant message block DEL+ADD ~30x over 17s).
Fix: reconcile the incoming array into a keyed solid-js/store mirror in
ChatMessages so each message keeps a stable identity across updates. MessageItem
already reads every field through accessors, so once it stops re-mounting only
the genuinely changed text/rows update in place. After the fix the observer
showed the assistant block mount ONCE per turn (ADD:1, DEL:0).
Contained to ChatMessages.tsx — no changes to useChat's 18 update sites and no
new dependency. Regression test proven to fail on the old reference-keyed <For>.
Residual: the per-message stream-event/tool-row list still re-mounts on tool
turns (groupStreamEventsForDisplay remaps to new objects) — a separate, narrower
follow-up, noted in the ai-runtime contract.
Asked 'what's the time' in autonomous mode, Pulse Assistant deflected
('I don't have access to a real-time clock... tell me a target host and I can
run `date`') while OpenCode just ran date and answered. Root cause: the
Assistant's per-turn system prompt carried no clock, and the heavy target_host
framing pushed the model to demand a host for any command.
Inject the current wall-clock time (Pulse server clock) into the per-turn
prompt in AgenticLoop.getSystemPrompt. getSystemPrompt is re-evaluated each
turn, so the timestamp stays fresh; the base prompt is frozen at service start
and must not carry it. The time is PII-free and safe on cloud-routed turns.
The Assistant now answers time/date questions directly with no command and no
target host.
Wire AIConfig.ShareOperationalContextWithCloud through /api/settings/ai so the
existing chat-path opt-in (commit 32d597267) is operator-reachable, not
config-file-only.
Backend (internal/api/ai_handlers.go): add share_operational_context_with_cloud
to the AI settings response (always serialized so a toggle can bind to the
concrete value) and to the update request as an optional *bool, applied
field-by-field exactly like discovery_enabled (omitted = persisted opt-in
unchanged).
Frontend: add a 'Share operational context with cloud models' toggle to the
Assistant runtime controls, bound to the canonical useAISettingsState form and
the api/ai.ts AISettings/AISettingsUpdateRequest payload. Help/summary copy
(PII-free scope, hostnames/IPs/aliases stay redacted, default off, local Ollama
always gets full context) lives in aiSettingsPresentation.ts.
Governance: substantive ai-runtime + frontend-primitives deltas plus
dependent-contract notes (api-contracts, agent-lifecycle, storage-recovery);
path-policy proofs in ai_handlers_test.go (round-trip), settingsArchitecture
and aiSettingsPresentation tests. JSON snapshot contracts updated for the new
always-serialized field.
Governed resources (every sensitive guest) were redacted to a terse
summary on cloud-routed Assistant turns, so the Assistant went blind on
cloud models -- generic non-answers for the majority of users who run
cloud providers.
Add AIConfig.ShareOperationalContextWithCloud (default false). When the
operator opts in and the turn routes to an external provider, the chat
prefetch path injects servicediscovery.FormatCloudSafeContext (service
identity, access command, config/data/log paths, ports -- PII-free) in
place of the terse governed redaction, and the model-bound resource
sanitizer allow-lists those exact spans so they survive the provider
boundary. Hostname/IP/alias/platform-id stay redacted regardless of the
opt-in.
When sharing is off on a cloud turn, the prefetch path instructs the
Assistant to disclose the redaction and point at the setting instead of
silently degrading the answer. Local (Ollama) routing is unaffected and
always receives full context.
Proof: internal/ai/chat/context_prefetch_cloud_context_test.go (opt-in =>
access path present, no hostname/IP; opt-out => governed redaction +
transparency; model-bound sanitizer strips raw PII while the allow-listed
cloud-safe span survives) and internal/config/ai_config_test.go.
ai-runtime contract updated for the new opt-in behavior.
Add a paced local Assistant fixture that exercises consecutive tool start, progress, completion, and replacement states without opening a provider request.
Seed Assistant turns with a local prompt-send status, promote it when the chat stream opens, and keep backend workflow activity as the durable stream evidence.
Use neutral assistant wait status while keeping selected model route metadata visible. Make transient retry copy explicit that Pulse retries the selected route rather than switching providers.