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.
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.
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.
Discovery-side enablement for making the Assistant useful on CLOUD models.
Today, sensitive resources route to cloud as a terse redacted summary, so
the Assistant never receives discovery's access context and gives generic
answers — invisibly broken for the majority of users who run cloud AI.
FormatCloudSafeContext returns the operational context the Assistant needs
(service identity, access pattern, config/data/log paths, port numbers)
while omitting PII by construction (no hostname, IP, bind addresses). The
chat sanitizer (Codex's ai/chat + unifiedresources policy lane) can include
this in cloud-routed summaries behind an opt-in, instead of withholding
everything. Local routing keeps using FormatForAIContext (full context).
Tested: includes service/access/paths/ports; rejects hostname + IP.
Cached discoveries from before the surface/fast-path/nested fixes still
counted as 'fresh' by the time-based window, so the panel showed worse
pre-fix data (what 'surely this isnt done?' surfaced on esphome).
Add servicediscovery.DiscoveryEngineVersion (currently 1), stamped onto
every freshly built discovery (LXC/VM and Docker build sites). Unlike
CLIAccessVersion it is NOT auto-upgraded on read, so a missing/older value
reliably means the result predates the current engine. The per-guest panel
shows an amber 're-run for improved results' nudge when engine version is
below current (CURRENT_DISCOVERY_ENGINE_VERSION, kept in sync).
Contract-neutral re: unified-resources (DiscoveryTab is a consumer; this is
a UI nudge + discovery field, no consumption-contract delta) — landed via
PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT. Go package + version-stamp test pass;
type-check + eslint clean.
The name fast-path identifies workloads named after their service, but a
workload with a generic name (ct101, db1) still fell through to the model —
which, with a slow reasoning model configured, times out rather than just
being slow. Many such workloads are still identifiable instantly by a
DISTINCTIVE listening port.
Add a Ports field to the identity table (only single-service ports: 8123
HA, 32400 Plex, 5432 postgres, 6379 redis, 1883/8883 mosquitto, 3306
mariadb, 8086 influxdb, 8096 jellyfin, 9090 prometheus, 6052 esphome) and
a second fast-path inferSurfaceIdentityFromPorts that runs after the name
path and before the model. Ambiguous ports (80, 443, 8080, 3000, 5000) are
deliberately excluded — a bad port guess is worse than asking the model.
Parser validated against real ss -tlnp output from LXC 101 (extracts 8123,
ignores ephemeral ports / PIDs / docker-proxy noise → home-assistant).
Unit tests cover distinctive-port matches and ambiguous-only non-matches.
The fast-path nailed identity but emitted the bare-guest access path, so
for a service running in Docker inside an LXC/VM (e.g. Home Assistant
Container) it told the Assistant to 'pct exec' into the guest shell —
wrong: the service is one layer deeper.
Access topology is index-level 'how to reach it' (only a probe can know a
service runs in a nested container), so re-add a LIGHT nested-container
probe (docker ps names+images, not the deep enumeration) to the LXC/VM
surface sets. When a nested container matches the identified service,
layer cli_access: '... docker exec <container> <command>'. Identity stays
instant from the name; the access path is corrected by one cheap command
when the agent is connected, and falls back to bare-guest guidance when not.
Verified live: HA LXC 101 (homeassistant container under Docker, alongside
watchtower) now yields cli_access including 'docker exec homeassistant',
in ~1s. Tests cover the match (HA by name, postgres by image), non-matches
(watchtower, unrelated service, no docker), and the cli_access layering.
Discovery should be instant for the obvious case: a workload named after
its service ('home-assistant', 'frigate', 'mqtt'…) is identified from the
name alone, with no model call and no command scan needed. This is the
'surface index' — identity + how-to-reach — with depth left to the
Assistant's own knowledge and on-demand commands.
Expand knownServiceIdentities from one entry (esphome) to the common
homelab set, and run inferSurfaceIdentity BEFORE the model: on a
name match, build the identity result and skip the (slow reasoning-model)
analysis entirely. Conservative — name signals only, never broad
command-output guesses — so the model is skipped only on an obvious match;
ambiguous workloads still fall through to full analysis.
Verified live on real infra: HA LXC now identifies in ~0s as Home
Assistant (0.9), no model call, even with the agent disconnected —
previously it timed out at 45s on the reasoning model.
Tests: new inferSurfaceIdentity coverage; updated three tests whose
fixtures were named after known services (they now take the fast-path) —
abstention test uses a generic name, repair test expects 0 model calls,
cached test uses a complete identity. Full package green.
Discovery is the index, not the encyclopedia: it needs to quickly answer
'what is this and how do I reach it', then the Assistant supplies
standard-service knowledge and runs commands on demand for specifics.
Trim the guest command sets to surface identity signals only (OS,
hostname, running services, listening ports, top processes for
LXC/VM; OS, processes, ports, env for Docker). Drop the deep
enumeration — installed_packages, config_files, docker_mounts,
hardware/GPU, disk, cron, nested docker_check — which bloated the
evidence payload (and the AI analysis) for no benefit the Assistant
can't get live. Remove the now-unused dockerMountsCommand const and
retire its test; add TestGuestCommandSetsAreSurfaceOnly to pin the
surface intent (verified live: HA LXC went from 13 commands to 5).
Note: full speed also needs a fast identification path (the configured
reasoning model still exceeds the 45s analysis timeout on its own) —
that's the follow-up.
Completes the resource-type matrix in the scenario corpus: LXC, Docker,
VM, and now k8s. A redis-pod cache-loss/restart question that needs
kubectl exec access, redis.conf, the rollout-restart command, and the
memory-limit fact — verified through both chat and remediation packs.
Test-only coverage; full servicediscovery package green.
The corpus covered LXC and Docker workloads but not VMs, which the agent
reaches via the QEMU guest agent (qm guest exec) rather than pct/docker
exec. Add a Plex-on-VM cell — a transcode-failure question that needs the
guest-exec access, the GPU decoder (hardware fact), and the restart
command — verified through both the chat and remediation packs. Test-only
coverage; full servicediscovery package green.
FormatForRemediation surfaced config and log paths but not data paths,
while FormatForAIContext (chat) does. For remediation those matter —
backup targets, disk-full triage, restore points (e.g. a database data
dir or HA's /config/.storage). Add a Data Directories section, matching
the chat pack. Extends the remediation test to assert a data path
reaches it; teeth-checked. Full servicediscovery package green.
Add two common-service cells to the context oracle. Beyond documenting
nginx and MQTT, they pin two code paths the existing cells did not cover:
the read-only bind-mount marker (nginx config mounted read-only) and
security-category fact surfacing (mosquitto auth). Both pass against the
current formatters (no production gap) — regression protection for the
iter4/iter5 mount + fact-filter work. Corpus now 6 cells (HA-LXC,
HA-Docker, postgres, frigate, nginx, mosquitto). Full package green.
FormatForRemediation (the discovery context Patrol/remediation consume)
surfaced CLI access, config/log paths and ONLY hardware facts — so the
context meant for fixing a workload never told you how to restart it or
where to edit its files on the host, the two core fix actions. Add a
'Service Control' section (service-category facts: systemd unit / restart
command) and a 'Bind Mounts (host -> container)' section, matching the
parity FormatForAIContext already has after iters 4-7.
New test asserts the restart command and host bind-mount source reach the
remediation context; teeth-checked. Build + vet + gofmt clean, full
servicediscovery package green.
Iter 5 added service+storage to the surfaced fact categories but the
context pack still capped at the first 5 facts by insertion order — so a
trailing service-control fact (how to restart the workload, the most
actionable one we just started capturing) could be silently dropped,
undermining iter 5-6.
Sort the priority facts by actionability (service > security >
dependency > hardware > version > storage, stable within category)
before capping, and raise the cap 5 -> 8 (still under the analyzer's
12-fact limit). The most useful facts now always survive.
Corpus: add a fact-heavy Frigate cell (6 priority facts, service-control
last). Filter test now asserts cap=8 and that a trailing service fact
sorts first and survives. Both teeth-checked. Full package green.
Iterations 4-5 made the context pack SURFACE Docker mounts and
service/storage facts; this closes the CAPTURE side so the analyzer
actually produces them. The workload analysis prompt asked for config
dirs but never for how to restart/reload the service or the specific
files a user edits. Add: (q9) how the service is managed/restarted; an
instruction to put specific key files (configuration.yaml,
automations.yaml, postgresql.conf) in config_paths rather than just the
parent dir; and an instruction to record the service-control mechanism
as a 'service'-category fact. Directly serves the 'reload my automation'
case.
Test pins both instructions in the built deep prompt; teeth-checked.
Build + vet + gofmt clean, full servicediscovery package green.
filterImportantFacts kept only hardware/dependency/security/version
facts, dropping 'service' and 'storage'. But a service fact (e.g. the
systemd unit) is exactly how the Assistant restarts/reloads a workload,
and a storage fact (the backing dataset/disk) is where its data lives —
neither is redundant with the CLI/path sections, and both are what a
real question like 'the database is slow, restart it' needs. Add both to
the priority categories.
Corpus: add a postgresql LXC cell whose required context includes the
systemd unit and data filesystem. Teeth-checked — the case fails without
the filter change. Build + vet + gofmt clean, full servicediscovery
package green.
Two-part start of the Discovery->Assistant context-completeness work.
1. FormatForAIContext (the context pack Chat/Patrol consume) dropped
DockerMounts, even though the model captures them. A container path
like /config is meaningless for editing or backing up persistent files
without its host source, so the Assistant could not act on a real
request like 'edit my blinds automation on the host'. Surface the
host -> container mapping (with read-only marker).
2. Add scenario_corpus_test.go — the verifiable oracle for the goal:
given a realistic discovered workload, the context pack must surface
everything the Assistant needs to answer a concrete user question with
zero re-explanation. Seeded with Home Assistant (LXC: pct exec +
automations.yaml + log; Docker: docker exec + bind-mount source). The
corpus grows one service-type cell at a time; a missing substring is a
concrete gap to close in the analyzer or formatter.
Teeth-checked: the Docker case fails without change #1. Build + vet clean,
full servicediscovery package green.