{copy("使用配置副本;仅发给本机服务。查询在你点击召回后才发给检索器。离开“记忆与资料”、切换 Goal 或清除时移除副本与结果。", "A profile copy is sent only to this local service. Your query reaches the provider only when you recall. Leaving Memory & sources, changing Goal or clearing removes the copy and results.")}
+ {!profile ?
{copy("尚未选择配置,不会调用检索器。", "No profile selected; no provider calls.")}
{copy("召回内容是不可信的参考材料。当前操作不写入原 profile、记忆、游标或 Goal,不启用自动 hook,也不授予执行权限。", "Retrieved content is untrusted advisory material. This operation does not write the original profile, memory, cursors or Goal, enable automatic hooks, or grant execution authority.")}
+ {reason ? {copy("诊断详情", "Diagnostics")}{status} · {reason} : null}
+
+ ;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx
index 73eda04803..c1e168c191 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx
@@ -15,10 +15,12 @@ import { useWorkspaceI18n } from "./i18n";
import { CapabilityConfigurationFields } from "./capability-configuration-fields";
import { withReportScheduleTimezone } from "./periodic-report-schedule-field";
import { localizeCapability, localizedCapabilityFieldCopy } from "./capability-localization";
-import { orderCapabilitiesForPresentation, canEditCapability, CapabilityCatalogNavigation, CapabilityConfigurationSummary, CapabilityDetailHeader, CapabilityEditorStatus } from "./capability-workbench";
+import { orderCapabilitiesForPresentation, canEditCapability, CapabilityCatalogNavigation, CapabilityConfigurationSummary, CapabilityDetailHeader, CapabilityEditorStatus, CapabilityConfigurationHelp } from "./capability-workbench";
import { GoalAutoNotifyToggle } from "./notification-settings-panel";
import { ContentReferenceWorkbench } from "./content-reference-workbench";
+import { DecisionContextWorkbench } from "./decision-context-workbench";
import { rewardMemoryBindingStatus, rewardMemoryScopeDraft, rewardMemoryWriteValue } from "./reward-memory-settings";
+import {isMemoryCapability, memorySettingsTitle, MemorySettingsHeader} from "./memory-settings";
import type { PersonalWorkspaceCallbacks, WorkspaceGoalNotification } from "./personal-workspace-model";
type CapabilityCatalogProps = Readonly<{
@@ -80,6 +82,9 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read
const [error, setError] = useState(null);
const [editorMode, setEditorMode] = useState<"guided" | "json">("guided");
const [jsonDraft, setJsonDraft] = useState("");
+ // Preserve the inspected Agent across fresh readbacks, never across Goals.
+ // Config paths and mutation previews still reset from the native snapshot.
+ const memoryScope = useRef({goalId, agentId: ""});
const parsedJson = useMemo(() => selected
? parseEditableCapabilityJson(selected.configuration_editor, jsonDraft) : null, [selected, jsonDraft]);
const jsonValid = editorMode === "guided" || parsedJson !== null;
@@ -97,6 +102,8 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read
selected?.configuration_editor ?? { fields: [] },
selected?.capability_id === "todo_replan_cadence"
? goalReplanCadenceEditorValue(selected.current, selected.effective_configuration?.configuration)
+ : selected?.capability_id === "reward_memory"
+ ? rewardMemoryScopeDraft(selected.current, memoryScope.current.goalId === goalId ? memoryScope.current.agentId : "")
: current,
selected?.default,
),
@@ -105,7 +112,7 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read
preview: null,
});
setError(null);
- }, [selected]);
+ }, [selected, goalId]);
async function preview(configuration: Record | null) {
if (!selected || loading || mutation.busy || !jsonValid) return;
@@ -149,6 +156,9 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read
function changeDraft(key: string, value: unknown) {
if (loading || mutation.busy) return;
+ if (selected?.capability_id === "reward_memory" && key === "agent_id") {
+ memoryScope.current = {goalId, agentId: typeof value === "string" ? value : ""};
+ }
setMutation((current) => ({
...current,
draft: selected?.capability_id === "periodic_report"
@@ -166,6 +176,9 @@ function useCapabilityMutation({ goalId, loading, onApplied, selected, t }: Read
if (!selected || loading || mutation.busy) return;
setJsonDraft(text);
const parsed = parseEditableCapabilityJson(selected.configuration_editor, text);
+ if (selected.capability_id === "reward_memory" && parsed) {
+ memoryScope.current = {goalId, agentId: typeof parsed.agent_id === "string" ? parsed.agent_id : ""};
+ }
setMutation((current) => ({ ...current, preview: null, draft: parsed
? projectEditableCapabilityConfiguration(selected.configuration_editor, parsed,
selected.capability_id === "todo_replan_cadence" ? undefined : selected.default)
@@ -271,6 +284,7 @@ function CapabilityCatalog({ callbacks, catalog, goalId, loading, notification,
// A refreshed observation can replace `selected` and reset local mutation
// state. The existing catalog read still owns admission until it settles.
const editorBusy = loading || Boolean(busy);
+ const memorySelected = selected ? isMemoryCapability(selected.capability_id) : false;
if (!selected || !localizedSelected) {
return
: null}
+
+ {cn ? "用途、检索器和用户偏好有什么区别?" : "Purposes, providers and user preferences"}
+
{cn ? "OpenViking、BM25 是检索实现,不是另一套记忆总开关。各用途保留自己的资料范围和配置,不会自动共享 Agent 私有内容。" : "OpenViking and BM25 are retrieval implementations, not separate memory master switches. Each purpose retains its own scope and configuration; Agent-private content is not automatically shared."}
+
{cn ? "你的明确要求与纠正使用完整的当前偏好版本,与检索出的参考材料分开;不能用搜索排名替代偏好读取。" : "Explicit instructions and corrections have a complete current preference view, separate from advisory search results. Ranked retrieval cannot replace that preference read."}
+
+ ;
+}
diff --git a/docs/reference/protocols/decision-context-advisory-provider-v0.md b/docs/reference/protocols/decision-context-advisory-provider-v0.md
index 033d410fda..6dfb49837b 100644
--- a/docs/reference/protocols/decision-context-advisory-provider-v0.md
+++ b/docs/reference/protocols/decision-context-advisory-provider-v0.md
@@ -132,6 +132,17 @@ queries, and distinguish provider latency from full caller wall time. Include
an unanswerable query; irrelevant candidates cannot establish answer correctness.
The local experiment report does not measure outcome utility or grant activation.
+The Goal Capability Center's **Decision evidence recall** workbench is a
+transient caller of this same port. Its local-only HTTP adapter accepts an
+explicit profile object and a bounded query in a POST body. Inspection reuses
+profile activation without provider access; recall reuses the existing native
+one-off scope, request bounds and advisory receipt. It creates no profile file,
+durable binding, cursor or settlement. Neither imported provider configuration
+nor the private query is echoed into the public receipt. Page-local controls
+do not change the original profile or enroll another Agent. Clearing or leaving
+the page removes its profile and hits; provider recovery uses the same native
+retry path. This UI transport is not another provider or decision owner.
+
Hook placement remains capability-owned: `agent-turn-recall` uses the admitted
Todo situation and qualified Reward Memory corpus; Semantic Preference delivers
explicit corrections; Decision Context rebases current materials. Verified
diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs
index 91d05653cf..86ee97a18f 100644
--- a/examples/personal-workspace-browser/fixture.mjs
+++ b/examples/personal-workspace-browser/fixture.mjs
@@ -145,7 +145,7 @@ function goalCapability({
};
}
-export function goalCapabilityCatalog(multiSubagentConfiguration) {
+export function goalCapabilityCatalog(multiSubagentConfiguration, rewardMemoryConfiguration) {
return [
goalCapability({
capabilityId: "todo_replan_cadence",
@@ -195,12 +195,31 @@ export function goalCapabilityCatalog(multiSubagentConfiguration) {
}),
multiSubagentCapability({ current: multiSubagentConfiguration }),
goalCapability({ availability: "retired", capabilityId: "local_authority_shadow", displayName: "Retired authority observation", fields: [], readOnlyReason: "Clear retired observation config explicitly; bootstrap runtime shadow separately." }),
- goalCapability({
+ {...goalCapability({
availability: "experimental_opt_in",
capabilityId: "reward_memory",
displayName: "Reward Memory experiment",
- fields: [],
+ fields: rewardMemoryConfiguration ? [
+ {key:"agent_id",label:"Agent",description:"",input_kind:"text",required:false},
+ {key:"enabled",label:"Enabled",description:"",input_kind:"boolean",required:false},
+ {key:"config_path",label:"Private config",description:"",input_kind:"text",required:false},
+ {key:"enabled_agents",label:"Shared Agents",description:"",input_kind:"string_list",required:false},
+ ] : [],
readOnlyReason: "Requires a reviewed local-private provider binding.",
+ }), ...(rewardMemoryConfiguration ? {current:rewardMemoryConfiguration} : {})},
+ goalCapability({
+ availability: "supported_explicit_opt_in",
+ capabilityId: "decision_context",
+ displayName: "Decision evidence recall",
+ fields: [],
+ readOnlyReason: "Use an explicit private profile for temporary recall.",
+ }),
+ goalCapability({
+ availability: "supported_explicit_opt_in",
+ capabilityId: "content_ops",
+ displayName: "Content references",
+ fields: [],
+ readOnlyReason: "Read-only source catalog.",
}),
];
}
@@ -465,6 +484,7 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu
goalSubagentWrites: [],
interrupts: [],
goalConfigurationRequests: [],
+ goalMemoryConfigurations: new Map(),
machineConfigurationRequests: [],
machineInspectionStatus: "configured",
failNextMachineInspection: false,
@@ -1343,7 +1363,7 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu
schema_version: "capability_configuration_catalog_v0",
capabilities: [...machineConfigurationBase.capability_catalog.capabilities.filter(
(capability) => !capability.available_scopes.includes("goal"),
- ), ...goalCapabilityCatalog(multiSubagentConfiguration).map((capability) => capability.capability_id === "periodic_report" ? periodicReportCapability({
+ ), ...goalCapabilityCatalog(multiSubagentConfiguration, state.goalMemoryConfigurations.get(goalId)).map((capability) => capability.capability_id === "periodic_report" ? periodicReportCapability({
machineCurrent: periodicConfiguration,
effectiveConfiguration: {
schema_version: "capability_configuration_resolution_v0",
diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs
index 6eb4081b21..7b185951f9 100644
--- a/examples/personal-workspace-browser/typed-actions.mjs
+++ b/examples/personal-workspace-browser/typed-actions.mjs
@@ -1082,7 +1082,7 @@ export const typedActionsScenario = {
const expectedGoalCapabilities = [
"变更质量验证", "Goal 复盘与步幅", "探索图谱", "探索 Harness", "飞书事件收件箱",
"飞书看板心跳同步", "已退役的 Authority 观测", "自适应子 Agent 容量",
- "已注册 Peer 任务协调", "周期报告", "Reward Memory 实验",
+ "已注册 Peer 任务协调", "周期报告", "记忆与资料",
];
if (JSON.stringify([...goalCapabilityOrder].sort()) !== JSON.stringify(expectedGoalCapabilities.sort())) {
throw new Error(`Goal capability workbench did not render the complete catalog: ${JSON.stringify(goalCapabilityOrder)}`);
@@ -1092,10 +1092,48 @@ export const typedActionsScenario = {
const capabilityIndex = (name) => goalCapabilityOrder.indexOf(name);
if (capabilityIndex("周期报告") >= capabilityIndex("探索 Harness")
|| capabilityIndex("自适应子 Agent 容量") <= capabilityIndex("探索 Harness")
- || capabilityIndex("自适应子 Agent 容量") >= capabilityIndex("已退役的 Authority 观测")
- || capabilityIndex("自适应子 Agent 容量") >= capabilityIndex("Reward Memory 实验")) {
+ || capabilityIndex("自适应子 Agent 容量") >= capabilityIndex("已退役的 Authority 观测")) {
throw new Error(`Goal capability maturity ordering drifted: ${JSON.stringify(goalCapabilityOrder)}`);
}
+ await page.locator(".personal-capability-list").getByRole("button", {name:"记忆与资料",exact:true}).click();
+ await page.getByRole("heading", {level:2,name:"记忆与资料",exact:true}).waitFor({state:"visible"});
+ const memoryTabs = page.getByRole("tablist", {name:"记忆用途"});
+ if (JSON.stringify(await memoryTabs.getByRole("tab").allTextContents()) !== JSON.stringify(["资料召回", "经验学习", "素材与风格"])) {
+ throw new Error("Memory navigation must expose the registered purposes inside one entry");
+ }
+ await page.getByRole("tab", {name:"资料召回",exact:true}).focus();
+ await page.keyboard.press("ArrowRight");
+ if (await page.getByRole("tab", {name:"经验学习",exact:true}).getAttribute("aria-selected") !== "true") {
+ throw new Error("Keyboard purpose navigation did not open the existing Agent experience editor");
+ }
+ // Authored native-shape snapshots exercise view continuity only; the
+ // real configuration/isolation owner is covered by the native suite.
+ const memoryGoalId = await page.getByLabel("目标 Goal", {exact:true}).inputValue();
+ const memoryView = {
+ registered_agents:["reviewer", "writer"], enabled_agents:["reviewer"],
+ binding_status:"available", binding_revision:`sha256:${"a".repeat(64)}`,
+ agent_bindings:[
+ {agent_id:"reviewer",binding_source:"agent",enabled:true,status:"available",automatic_recall:true,automatic_ingest:true},
+ {agent_id:"writer",binding_source:"agent",enabled:false,status:"disabled",automatic_recall:false,automatic_ingest:false},
+ ],
+ };
+ api.goalMemoryConfigurations.set(memoryGoalId, memoryView);
+ await page.getByRole("button", {name:"重新读取配置",exact:true}).click();
+ const memoryScope = page.getByLabel("经验作用范围", {exact:true});
+ await memoryScope.waitFor({state:"visible"});
+ await memoryScope.selectOption("writer");
+ api.goalMemoryConfigurations.set(memoryGoalId, {...memoryView,binding_revision:`sha256:${"b".repeat(64)}`});
+ await page.getByRole("button", {name:"重新读取配置",exact:true}).click();
+ await page.waitForFunction(revision => document.querySelector(".personal-capability-raw-values")?.textContent?.includes(revision), "b".repeat(64));
+ if (await memoryScope.inputValue() !== "writer") {
+ throw new Error("A fresh native readback silently switched the selected Agent to the shared binding");
+ }
+ if (await page.getByLabel("已启用的 Goal Agent", {exact:true}).count()) {
+ throw new Error("An independent Agent editor exposed the shared allowlist");
+ }
+ await page.keyboard.press("Home");
+ await page.getByRole("tab", {name:"资料召回",exact:true}).click();
+ await page.getByRole("region", {name:"资料召回",exact:true}).waitFor({state:"visible"});
await page.locator(".personal-capability-list").getByRole("button", { name: "已退役的 Authority 观测" }).click();
const retiredDetail = page.locator(".personal-capability-detail");
await retiredDetail.getByText(/--clear-local-authority-shadow/).waitFor();
diff --git a/loopx/capabilities/decision_context/README.md b/loopx/capabilities/decision_context/README.md
index 57dc2a12c6..558bfe9fdc 100644
--- a/loopx/capabilities/decision_context/README.md
+++ b/loopx/capabilities/decision_context/README.md
@@ -211,6 +211,60 @@ retains only the query summary, provider-safe summaries, scores, and hashed
references. Each recalled item is marked `untrusted_advisory` and must never be
treated as an instruction.
+The Goal workspace groups the existing capabilities under **Capability Center
+→ Memory & sources**. **Find evidence** uses a temporary profile for a question;
+**Learn from outcomes** retains saved Agent bindings and a separate legacy shared
+binding; **Reference styles** reads attributed writing material. OpenViking and
+BM25 are retrieval implementations, not extra memory switches. Explicit Agent
+preferences retain their complete current-view lifecycle, separately from ranked
+advisory results.
+
+Choose **Find evidence**, import an existing private profile JSON, use its enabled Agent and
+provider scope, then enter a question and select **Recall evidence**. Import
+only inspects the native activation rules; it does not access the provider.
+The profile is sent to the local Chat service in a bounded POST body, without
+writing a temporary profile or adding a persistent Goal configuration. The
+query reaches the selected provider only when recall is requested.
+
+The enable control changes the page's copy only. **Clear temporary profile and
+results**, leaving Memory & sources, changing the Goal target, or reloading removes that copy and its hits;
+the original profile remains unchanged. Goal and Agent activation, request
+bounds, provider selection and ranking use the same owners as the CLI. Disabled,
+invalid or mismatched profiles cannot call a provider. A failed recall clears
+old hits and shows the native status; restore the provider or corpus and retry.
+Use the optional retrieval controls for a one-off scope or result limit. Native
+scores apply only within that provider, not across providers or as confidence.
+The page does not enable automatic capture, host hooks, Reward Memory bindings,
+evidence settlement or execution authority. Switching between memory purposes
+preserves the temporary profile, question and completed hits; an in-flight recall
+is cancelled when its pane is deactivated. Its recalled text remains private
+and untrusted; it is not a public artifact or a validated decision.
+
+
+
+In **Learn from outcomes**, choose a registered Agent before previewing a saved
+binding. The selection survives configuration reload and apply; changing the
+Goal resets it. The legacy shared binding has its own explicit choice. The
+readback distinguishes configured automatic recall/writeback from verified
+execution of those paths.
+
+
+
+Source-built workspace with synthetic local evidence; this illustrates the
+interaction, not production adoption or retrieval-quality results.
+
+
+Scope recovery and narrow layouts
+
+A wrong one-off scope returns `scope_mismatch` with no retained hits. Restore
+the corpus scope in the retrieval controls and retry; the original profile is
+unchanged. The same native recall remains available on a narrow viewport.
+
+
+
+
+
+
Keeping this profile enabled does not make Obelisk a required LoopX
dependency. If the selected extension is not installed, is disabled, or no
longer has a current doctor proof, recall exits normally with
diff --git a/loopx/capabilities/decision_context/README.zh-CN.md b/loopx/capabilities/decision_context/README.zh-CN.md
index f16a23e731..5c3d535de7 100644
--- a/loopx/capabilities/decision_context/README.zh-CN.md
+++ b/loopx/capabilities/decision_context/README.zh-CN.md
@@ -188,6 +188,47 @@ Agent 使用的召回原文;嵌套 retrieval receipt 保持 public-safe,只
provider-safe 摘要、分数与哈希引用。每个召回 item 都标记为
`untrusted_advisory`,不得当作指令执行。
+Goal 工作区将既有能力归到 **能力中心 → 记忆与资料**:**资料召回**为当前问题临时
+检索材料;**经验学习**保存 Agent 绑定,并单独管理旧版共享绑定;**素材与风格**
+读取带来源署名的表达素材。OpenViking、BM25 是检索实现,不是额外的记忆开关。
+明确的 Agent 偏好仍保留完整当前版本的生命周期,与排名后的参考材料分开。
+
+选择 **资料召回**,导入现有私有 profile JSON,
+复用其中启用的 Agent 与 provider scope,输入问题后选择 **召回证据**。导入只检查
+原生 activation 规则,不访问 provider。Profile 经有界 POST body 发送给本机 Chat
+服务,不写临时 profile 文件,也不新增持久化 Goal 配置;只有发起召回时,查询才会
+发送给所选 provider。
+
+启用控件只改变本页副本。选择 **清除临时配置与结果**、离开“记忆与资料”、切换目标 Goal 或刷新页面,即
+移除副本与结果,原 profile 不变。Goal/Agent activation、请求边界、provider 选择
+和排序与 CLI 复用同一 owner。停用、无效或不匹配的 profile 不会调用 provider。
+召回失败会清空旧结果并显示原生状态;恢复 provider 或语料后可重试。可选检索控件
+允许调整本次 scope 或结果数。原生分数只适用于该 provider 内的排序,不能跨 provider
+比较或当作置信度。本页不启用自动采集、宿主 hook、Reward Memory 绑定、证据结算或
+执行权限。用途之间切换会保留临时配置、问题和已完成的结果;停用检索面板时取消
+尚未完成的召回。召回原文仍是私有、不可信的参考材料,不是公开产物或已验证决策。
+
+
+
+在 **经验学习** 中先选择已注册 Agent,再预览持久绑定的变更。重新读取和应用
+配置后保留所选 Agent,切换 Goal 时重置;旧版共享绑定有独立选项。读回中的
+自动召回、自动写回表示配置值,实际执行这两条路径仍需各自验证。
+
+
+
+源码构建的工作区,使用合成本地证据展示交互;不表示生产采用或检索质量结论。
+
+
+Scope 恢复与窄屏交互
+
+本次 scope 错误时返回 `scope_mismatch`,不会保留旧结果。在检索控件中恢复语料的
+scope 后重试即可,原 profile 不变。窄屏仍使用同一原生召回入口。
+
+
+
+
+
+
保持该 profile 启用,并不意味着 Obelisk 会变成 LoopX 的必需依赖。如果指定的
extension 尚未安装、已禁用,或缺少当前有效的 doctor 证明,召回仍会正常退出,
返回 `status=unavailable` 和类型化的 `provider_readiness` 回执,并且不会执行
diff --git a/loopx/capabilities/decision_context/assets/workspace-experience.zh.png b/loopx/capabilities/decision_context/assets/workspace-experience.zh.png
new file mode 100644
index 0000000000..65a77cc804
Binary files /dev/null and b/loopx/capabilities/decision_context/assets/workspace-experience.zh.png differ
diff --git a/loopx/capabilities/decision_context/assets/workspace-memory-before.zh.png b/loopx/capabilities/decision_context/assets/workspace-memory-before.zh.png
new file mode 100644
index 0000000000..d386700b6b
Binary files /dev/null and b/loopx/capabilities/decision_context/assets/workspace-memory-before.zh.png differ
diff --git a/loopx/capabilities/decision_context/assets/workspace-recall.en.png b/loopx/capabilities/decision_context/assets/workspace-recall.en.png
new file mode 100644
index 0000000000..49c19db187
Binary files /dev/null and b/loopx/capabilities/decision_context/assets/workspace-recall.en.png differ
diff --git a/loopx/capabilities/decision_context/assets/workspace-recall.narrow.en.png b/loopx/capabilities/decision_context/assets/workspace-recall.narrow.en.png
new file mode 100644
index 0000000000..a1dfe13108
Binary files /dev/null and b/loopx/capabilities/decision_context/assets/workspace-recall.narrow.en.png differ
diff --git a/loopx/capabilities/decision_context/assets/workspace-recall.scope-error.zh.png b/loopx/capabilities/decision_context/assets/workspace-recall.scope-error.zh.png
new file mode 100644
index 0000000000..35d528b669
Binary files /dev/null and b/loopx/capabilities/decision_context/assets/workspace-recall.scope-error.zh.png differ
diff --git a/loopx/capabilities/decision_context/assets/workspace-recall.zh.png b/loopx/capabilities/decision_context/assets/workspace-recall.zh.png
new file mode 100644
index 0000000000..95a35ada12
Binary files /dev/null and b/loopx/capabilities/decision_context/assets/workspace-recall.zh.png differ
diff --git a/loopx/capabilities/decision_context/catalog_entry.py b/loopx/capabilities/decision_context/catalog_entry.py
index ee8dab4ccf..c3bbadac14 100644
--- a/loopx/capabilities/decision_context/catalog_entry.py
+++ b/loopx/capabilities/decision_context/catalog_entry.py
@@ -5,6 +5,21 @@
from typing import Any
+def decision_context_configuration_descriptor() -> dict[str, Any]:
+ return {
+ "feature_id": "decision_context",
+ "display_name": "Decision evidence recall",
+ "availability": "local_artifact_preview",
+ "effect": (
+ "Inspect an existing private profile and recall advisory evidence in "
+ "this page. No profile, cursor, settlement, or Core writes."
+ ),
+ "documentation": {
+ "path": "loopx/capabilities/decision_context/README.md",
+ },
+ }
+
+
DECISION_CONTEXT_CATALOG_ENTRY: dict[str, Any] = {
"id": "decision-context",
"origin": "builtin",
diff --git a/loopx/capabilities/decision_context/profile.py b/loopx/capabilities/decision_context/profile.py
index ea82eeb87b..ce23e8022c 100644
--- a/loopx/capabilities/decision_context/profile.py
+++ b/loopx/capabilities/decision_context/profile.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import json
-from collections.abc import Collection, Mapping, Sequence
+from collections.abc import Callable, Collection, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -411,6 +411,38 @@ def resolve_decision_context_activation(
agent_id: str,
profile_path: Path | None,
available_source_provider_ids: Collection[str] | None = None,
+) -> tuple[dict[str, Any], DecisionContextProfile | None]:
+ return _resolve_profile_activation(
+ goal_id=goal_id,
+ agent_id=agent_id,
+ load_profile=(lambda: load_decision_context_profile(profile_path))
+ if profile_path is not None else None,
+ available_source_provider_ids=available_source_provider_ids,
+ )
+
+
+def resolve_inline_decision_context_activation(
+ *,
+ goal_id: str,
+ agent_id: str,
+ profile_payload: Mapping[str, Any] | None,
+) -> tuple[dict[str, Any], DecisionContextProfile | None]:
+ """Inspect a request-owned profile without a temporary file or provider IO."""
+ return _resolve_profile_activation(
+ goal_id=goal_id,
+ agent_id=agent_id,
+ load_profile=(lambda: normalize_decision_context_profile(profile_payload))
+ if profile_payload is not None else None,
+ available_source_provider_ids=None,
+ )
+
+
+def _resolve_profile_activation(
+ *,
+ goal_id: str,
+ agent_id: str,
+ load_profile: Callable[[], DecisionContextProfile] | None,
+ available_source_provider_ids: Collection[str] | None,
) -> tuple[dict[str, Any], DecisionContextProfile | None]:
normalized_goal = _token(goal_id, field_name="goal_id")
normalized_agent = _token(agent_id, field_name="agent_id")
@@ -428,7 +460,7 @@ def resolve_decision_context_activation(
"default_enabled": False,
"goal_id": normalized_goal,
"agent_id": normalized_agent,
- "profile_configured": profile_path is not None,
+ "profile_configured": load_profile is not None,
"enabled": False,
"configured_for_agent": False,
"available": False,
@@ -443,10 +475,10 @@ def resolve_decision_context_activation(
"private_locator_captured": False,
"credentials_captured": False,
}
- if profile_path is None:
+ if load_profile is None:
return base | {"status": "disabled"}, None
try:
- config = load_decision_context_profile(profile_path)
+ config = load_profile()
except ValueError:
return base | {
"status": "profile_invalid",
diff --git a/loopx/capabilities/decision_context/runtime.py b/loopx/capabilities/decision_context/runtime.py
index 347450494a..51a84ba383 100644
--- a/loopx/capabilities/decision_context/runtime.py
+++ b/loopx/capabilities/decision_context/runtime.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from collections.abc import Collection, Mapping, Sequence
+from collections.abc import Callable, Collection, Mapping, Sequence
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
@@ -26,6 +26,7 @@
from .profile import (
DecisionContextProfile,
resolve_decision_context_activation,
+ resolve_inline_decision_context_activation,
)
from .providers import build_decision_source_provider
from .sources import DecisionSourceProvider, DecisionSourceScan, DecisionSourceSpec
@@ -444,6 +445,71 @@ def recall_profile_decision_context(
reason_code="profile_unavailable_or_invalid",
)
+ return _recall_activated_context(
+ activation=activation,
+ profile=profile,
+ context_scope_ref=context_scope_ref,
+ query=query,
+ query_summary=query_summary,
+ observed_at=observed_at,
+ max_results=max_results,
+ timeout_seconds=timeout_seconds,
+ runtime_root=runtime_root,
+ profile_current=lambda: profile_digest_before == _private_profile_digest(profile_path),
+ )
+
+
+def recall_inline_decision_context(
+ *,
+ goal_id: str,
+ agent_id: str,
+ profile_payload: Mapping[str, Any] | None,
+ context_scope_ref: str,
+ query: str,
+ query_summary: str,
+ observed_at: str,
+ max_results: int | None = None,
+ timeout_seconds: float | None = None,
+ runtime_root: str | Path | None = None,
+) -> dict[str, Any]:
+ """Use the same recall owner with a transient, request-owned profile copy."""
+ activation, profile = resolve_inline_decision_context_activation(
+ goal_id=goal_id, agent_id=agent_id, profile_payload=profile_payload,
+ )
+ return _recall_activated_context(
+ activation=activation,
+ profile=profile,
+ context_scope_ref=context_scope_ref,
+ query=query,
+ query_summary=query_summary,
+ observed_at=observed_at,
+ max_results=max_results,
+ timeout_seconds=timeout_seconds,
+ runtime_root=runtime_root,
+ )
+
+
+def _recall_activated_context(
+ *,
+ activation: Mapping[str, Any],
+ profile: DecisionContextProfile | None,
+ context_scope_ref: str,
+ query: str,
+ query_summary: str,
+ observed_at: str,
+ max_results: int | None,
+ timeout_seconds: float | None,
+ runtime_root: str | Path | None,
+ profile_current: Callable[[], bool] | None = None,
+) -> dict[str, Any]:
+ base = _ephemeral_recall_base(activation)
+ if activation.get("available") is not True or profile is None:
+ return _ephemeral_recall_failure(
+ base,
+ status=str(activation.get("status") or "unavailable"),
+ reason_code=activation.get("reason_code"),
+ )
+
context_config = profile.context_provider
if context_config is None:
return _ephemeral_recall_failure(
@@ -479,8 +545,7 @@ def recall_profile_decision_context(
status="unavailable",
reason_code="context_provider_not_configured",
)
- profile_digest_after = _private_profile_digest(profile_path)
- if profile_digest_before != profile_digest_after:
+ if profile_current is not None and not profile_current():
return _ephemeral_recall_failure(
base,
status="unavailable",
diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py
index 65a992a0fe..7e50ce4c0f 100644
--- a/loopx/configuration_catalog.py
+++ b/loopx/configuration_catalog.py
@@ -6,6 +6,7 @@
from .capabilities.configuration_ui import build_capability_configuration_catalog
from .capabilities.content_ops.reference import reference_configuration_descriptor
+from .capabilities.decision_context.catalog_entry import decision_context_configuration_descriptor
from .control_plane.agent_context import agent_context_descriptor
from .explore_graph import explore_configuration
from .capabilities.todo_replan_cadence.machine_defaults import default_replan_cadence_configuration
@@ -889,6 +890,7 @@ def build_goal_configuration_catalog(
}
)
catalog["features"].append(reference_configuration_descriptor())
+ catalog["features"].append(decision_context_configuration_descriptor())
overrides = machine_inheritable_goal_overrides or {}
for feature in catalog["features"]:
feature_id = str(feature.get("feature_id") or "")
diff --git a/loopx/presentation/configuration_api.py b/loopx/presentation/configuration_api.py
index fac088c452..38fea2a0a4 100644
--- a/loopx/presentation/configuration_api.py
+++ b/loopx/presentation/configuration_api.py
@@ -13,6 +13,7 @@
from .. import chat_usage_statistics_api as usage_api
from .. import chat_goal_configuration_api as goal_api
from . import goal_direction_api as direction_api
+from . import decision_context_api as context_api
from .. import chat_machine_configuration_api as machine_api
from .. import chat_operator_provider_api as operator_api
from .. import chat_automation_cadence_api as cadence_api
@@ -29,6 +30,7 @@ class ChatConfigurationRequestMixin(
cadence_api.AutomationCadenceRequestMixin,
goal_api.GoalConfigurationRequestMixin,
direction_api.GoalDirectionRequestMixin,
+ context_api.DecisionContextRequestMixin,
machine_api.MachineConfigurationRequestMixin,
operator_api.OperatorProviderRequestMixin,
backup_api.ConfigurationBackupRequestMixin,
@@ -57,6 +59,7 @@ def _configuration_get_routes(self) -> dict[str, Callable[[], None]]:
def _configuration_post_routes(self) -> dict[str, Callable[[], None]]:
return {
+ context_api.DECISION_CONTEXT_PATH: self._decision_context_request,
attached_api.CODEX_ATTACHED_PATH: self._codex_attached_send,
attached_api.CODEX_ATTACHED_OPEN_PATH: self._codex_attached_open,
request_api.REQUEST_CONTROL_PATH: self._request_control_write,
diff --git a/loopx/presentation/decision_context_api.py b/loopx/presentation/decision_context_api.py
new file mode 100644
index 0000000000..eefc804fd3
--- /dev/null
+++ b/loopx/presentation/decision_context_api.py
@@ -0,0 +1,76 @@
+"""Loopback-only, transient UI adapter to the existing Decision Context owner."""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+from urllib.parse import urlparse
+
+from ..capabilities.decision_context.profile import resolve_inline_decision_context_activation
+from ..capabilities.decision_context.runtime import recall_inline_decision_context
+from ..control_plane.projects.registry_codec import load_registry
+from ..feedback import validate_goal_id
+from ..registry import find_registry_goal
+from .goal_source_routing import source_registry_for_goal
+
+DECISION_CONTEXT_PATH = "/api/chat/decision-context"
+
+
+class DecisionContextRequestMixin:
+ server: Any
+ path: str
+
+ def _decision_context_request(self) -> None:
+ try:
+ if urlparse(self.path).query:
+ raise ValueError("Use a private request body")
+ body = self._read_json(max_bytes=256 * 1024)
+ required = {"action", "goal_id", "agent_id", "profile"}
+ allowed = required | {"query", "context_scope_ref", "max_results"}
+ if not required <= set(body) or set(body) - allowed:
+ raise ValueError("Unsupported fields")
+ action = body["action"]
+ if action not in {"inspect", "recall"}:
+ raise ValueError("Unsupported operation")
+ if action == "inspect" and set(body) != required:
+ raise ValueError("Inspection cannot recall")
+ goal_id, agent_id = body["goal_id"], body["agent_id"]
+ if not isinstance(goal_id, str) or not isinstance(agent_id, str):
+ raise ValueError("A Goal and Agent are required")
+ validate_goal_id(goal_id)
+ selected = getattr(self.server, "selected_goal_id", None)
+ if selected and selected != goal_id:
+ raise ValueError("Goal is outside this workspace")
+ source = source_registry_for_goal(self.server.registry_path, goal_id)
+ if find_registry_goal(load_registry(source), goal_id) is None:
+ raise ValueError("Goal is not registered")
+ profile = body["profile"]
+ if profile is not None and not isinstance(profile, dict):
+ raise ValueError("Profile must be an object")
+ if action == "inspect":
+ activation, _ = resolve_inline_decision_context_activation(
+ goal_id=goal_id, agent_id=agent_id, profile_payload=profile,
+ )
+ self._send_json(activation)
+ return
+ if not {"query", "context_scope_ref"} <= set(body) or any(
+ not isinstance(body[field], str) for field in ("query", "context_scope_ref")
+ ):
+ raise ValueError("A bounded query and scope are required")
+ self._send_json(recall_inline_decision_context(
+ goal_id=goal_id,
+ agent_id=agent_id,
+ profile_payload=profile,
+ context_scope_ref=body["context_scope_ref"],
+ query=body["query"],
+ # The private query never becomes the public receipt summary.
+ query_summary="Decision evidence requested in the local workspace",
+ observed_at=datetime.now(timezone.utc).isoformat(),
+ max_results=body.get("max_results"),
+ runtime_root=self.server.runtime_root,
+ ))
+ except (OSError, TypeError, ValueError):
+ # Never echo imported credentials, provider config, query or paths.
+ self._send_error(
+ "Decision Context request unavailable. Check the selected Goal, Agent and profile, then retry.",
+ status=400,
+ )
diff --git a/tests/presentation/test_decision_context_api.py b/tests/presentation/test_decision_context_api.py
new file mode 100644
index 0000000000..ecde1a2b03
--- /dev/null
+++ b/tests/presentation/test_decision_context_api.py
@@ -0,0 +1,193 @@
+"""Real HTTP dispatch and shared BM25; no active Goal or provider credentials."""
+from __future__ import annotations
+
+import http.client
+import json
+import threading
+
+import pytest
+
+from loopx.capabilities.decision_context.profile import resolve_decision_context_activation
+from loopx.capabilities.decision_context.runtime import recall_profile_decision_context
+from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+
+GOAL = "evidence-demo"
+AGENT = "researcher"
+PATH = "/api/chat/decision-context"
+CONTENT = "The migration preserves transaction authority and typed semantics."
+
+
+@pytest.fixture
+def api(tmp_path):
+ corpus = tmp_path / "corpus.json"
+ corpus.write_text(json.dumps({
+ "schema_version": "local_bm25_corpus_v0", "scope_ref": "corpus:demo",
+ "documents": [{"id": "semantics", "content": CONTENT}],
+ }))
+ profile = {
+ "schema_version": "decision_context_profile_v0", "goal_id": GOAL,
+ "enabled": True, "enabled_agents": [AGENT],
+ "source_provider_bindings": [], "sources": [],
+ "context_provider": {"provider": "bm25", "namespace": "decision-context",
+ "scope_ref": "corpus:demo", "max_results": 4, "timeout_seconds": 2,
+ "config": {"corpus_path": str(corpus)}},
+ "automation": {"automatic_capture": False, "fail_open": True},
+ }
+ registry = tmp_path / "registry.json"
+ registry.write_text(json.dumps({
+ "common_runtime_root": str(tmp_path / "runtime"),
+ "goals": [{"id": GOAL, "repo": str(tmp_path)}],
+ }))
+ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+ server.registry_path, server.runtime_root = registry, tmp_path / "runtime"
+ server.selected_goal_id, server.verbose = GOAL, False
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ def call(*, action="inspect", override=None, origin=None, suffix=""):
+ body = {"action": action, "goal_id": GOAL, "agent_id": AGENT, "profile": profile}
+ if action == "recall":
+ body.update(query="transaction authority", context_scope_ref="corpus:demo", max_results=4)
+ body.update(override or {})
+ connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=10)
+ headers = {"Content-Type": "application/json"}
+ if origin:
+ headers["Origin"] = origin
+ connection.request("POST", PATH + suffix, json.dumps(body), headers)
+ response = connection.getresponse()
+ result = json.loads(response.read())
+ assert response.getheader("Cache-Control") == "no-store"
+ connection.close()
+ return response.status, result
+
+ yield call, profile, corpus, server
+ server.shutdown()
+ server.server_close()
+ thread.join(5)
+
+
+def test_real_recall_matches_file_entry_without_persisting_profile_or_query(api, tmp_path):
+ call, profile, corpus, _ = api
+ original = tmp_path / "original-profile.json"
+ original.write_text(json.dumps(profile))
+ before = {path: path.read_bytes() for path in tmp_path.rglob("*") if path.is_file()}
+ code, activation = call()
+ assert code == 200 and activation["available"]
+ assert activation == resolve_decision_context_activation(
+ goal_id=GOAL, agent_id=AGENT, profile_path=original,
+ )[0]
+ code, recall = call(action="recall")
+ assert code == 200 and recall["ok"] and recall["results"][0]["content"] == CONTENT
+ native = recall_profile_decision_context(
+ goal_id=GOAL, agent_id=AGENT, profile_path=original,
+ context_scope_ref="corpus:demo", query="transaction authority",
+ query_summary=recall["query_summary"], observed_at=recall["observed_at"],
+ max_results=4,
+ )
+ assert recall["results"] == native["results"]
+ assert recall["provider"] == native["provider"] == "bm25"
+ for field in ("profile_write_performed", "source_scan_performed", "cursor_state_read",
+ "cursor_state_mutated", "pending_settlement_written", "raw_content_persisted",
+ "execution_authorized", "external_writes_performed"):
+ assert recall[field] is False
+ assert recall["content_may_instruct"] is False
+ receipt = json.dumps(recall["retrieval_receipt"])
+ assert CONTENT not in receipt and str(corpus) not in receipt
+ assert "transaction authority" not in receipt
+ after = {path: path.read_bytes() for path in tmp_path.rglob("*") if path.is_file()}
+ assert after == before
+
+
+@pytest.mark.parametrize("patch, expected", [
+ (None, "disabled"),
+ ({"enabled": False}, "disabled"),
+ ({"enabled_agents": ["another-agent"]}, "agent_not_enabled"),
+ ({"goal_id": "another-goal"}, "profile_invalid"),
+ ({"schema_version": "invalid"}, "profile_invalid"),
+])
+def test_inactive_profiles_never_build_or_call_provider(api, monkeypatch, patch, expected):
+ from loopx.capabilities.decision_context import runtime
+ call, profile, _, _ = api
+
+ def forbidden(*args, **kwargs):
+ pytest.fail("inactive profiles must not reach provider construction")
+
+ monkeypatch.setattr(runtime, "_build_advisory_context_provider", forbidden)
+ value = None if patch is None else profile | patch
+ assert call(override={"profile": value})[1]["status"] == expected
+ code, result = call(action="recall", override={"profile": value})
+ assert code == 200 and not result["ok"] and result["status"] == expected
+ assert result["results"] == []
+
+
+def test_wrong_scope_provider_failure_and_real_file_recovery(api):
+ call, _, corpus, _ = api
+ code, mismatch = call(action="recall", override={"context_scope_ref": "corpus:foreign"})
+ assert code == 200 and not mismatch["ok"] and mismatch["reason_code"] == "scope_mismatch"
+ content = corpus.read_bytes()
+ corpus.unlink()
+ unavailable = call(action="recall")[1]
+ assert not unavailable["ok"] and unavailable["reason_code"] == "corpus_unavailable"
+ corpus.write_bytes(content)
+ recovered = call(action="recall")[1]
+ assert recovered["ok"] and recovered["results"][0]["content"] == CONTENT
+ assert call(action="recall", override={"query": "unmatchedterm"})[1]["results"] == []
+
+
+def test_source_profile_without_recall_provider_is_not_recall_readiness(api):
+ call, profile, corpus, _ = api
+ override = {"profile": profile | {
+ "context_provider": None,
+ "source_provider_bindings": [{"provider_id": "authority", "adapter": "local-file", "config": {}}],
+ "sources": [{"source_id": "source:authority", "provider_id": "authority", "source_kind": "artifact",
+ "priority": "p0", "evidence_level": "s3", "objectives": ["architecture"],
+ "scan_mode": "incremental", "exact_read_policy": "material_change", "freshness_seconds": 3600,
+ "private_locator": str(corpus), "max_changes": 4, "enabled": True}],
+ }}
+ activation = call(override=override)[1]
+ assert activation["available"] is True and activation["context_provider_configured"] is False
+ result = call(action="recall", override=override)[1]
+ assert not result["ok"] and result["reason_code"] == "context_provider_not_configured"
+ assert result["results"] == []
+
+
+@pytest.mark.parametrize("override", [
+ {"action": "capture"}, {"profile_path": "private-profile.json"},
+ {"goal_id": "other-goal"}, {"agent_id": ""}, {"profile": []},
+ {"max_results": True}, {"max_results": 9}, {"query": "x" * 1001},
+ {"context_scope_ref": ""},
+])
+def test_request_bounds_and_private_field_errors_do_not_echo_input(api, tmp_path, override):
+ call, _, _, _ = api
+ code, refused = call(action="recall", override=override)
+ assert code == 400 and not refused["ok"]
+ assert str(tmp_path) not in json.dumps(refused)
+ assert "private-profile" not in json.dumps(refused)
+
+
+def test_origin_query_and_registered_goal_are_enforced_before_recall(api):
+ call, _, _, server = api
+ assert call(action="recall", origin="https://foreign.example")[0] == 403
+ assert call(suffix="?query=must-not-log-private-queries")[0] == 400
+ assert call(override={"query": "inspection-must-not-recall"})[0] == 400
+ registry = json.loads(server.registry_path.read_text())
+ registry["goals"] = []
+ server.registry_path.write_text(json.dumps(registry))
+ assert call()[0] == 400
+
+
+@pytest.mark.parametrize("action", ["inspect", "recall"])
+def test_lifecycle_only_registry_is_refused_before_provider_construction(api, monkeypatch, action):
+ from loopx.capabilities.decision_context import runtime
+ call, _, _, server = api
+
+ def forbidden(*args, **kwargs):
+ pytest.fail("lifecycle-only registries must not reach provider construction")
+
+ monkeypatch.setattr(runtime, "_build_advisory_context_provider", forbidden)
+ server.registry_path.write_text(json.dumps({
+ "profile_id": "source_session_v1", "goals": [{"id": GOAL}],
+ }))
+ code, refused = call(action=action)
+ assert code == 400 and not refused["ok"]
+ assert "Decision Context request unavailable" in json.dumps(refused)