@akira-tl/forgerelay 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/capabilities/workspace/workspace-conversation-health/GUIDE.md +36 -0
- package/dist/activity/history/host-turn-store.js +10 -0
- package/dist/activity/history/query-service.js +6 -0
- package/dist/mcp/panel/app.js +34 -6
- package/dist/mcp/panel/mcp-app-template.js +1 -1
- package/dist/mcp/server/core/capabilities/workspace-checkpoint-runtime.js +34 -0
- package/dist/mcp/server/core/capabilities/workspace-checkpoint.js +17 -0
- package/dist/mcp/server/core/capabilities/workspace-conversation-health.js +19 -0
- package/dist/mcp/server/core/capabilities.js +7 -0
- package/dist/mcp/server/core/capability-registry.js +4 -13
- package/dist/mcp/server/operations/runtime/operation-runtime.js +2 -0
- package/dist/mcp/server/workspace/runtime/conversation-health-relay.js +30 -0
- package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +3 -3
- package/dist/mcp/server/workspace/runtime/workspace-open.js +1 -1
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +7 -1
- package/dist/server.js +18 -24
- package/dist/workspaces/state/conversation-health.js +124 -0
- package/dist/workspaces/state/workspace-checkpoints.js +15 -0
- package/dist/workspaces/tasks/workspace-tasks.js +5 -0
- package/docs/configuration.md +14 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.3.4] - 2026-10-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 新增只读 `workspace.conversation-health` Capability:仅使用 ForgeRelay 可观察的 Host Turn、持久 Workspace checkpoint、Task 与 Git 状态给出 `healthy`、`checkpoint-recommended` 或 `rotation-recommended` 建议,不推测 Host token、上下文窗口利用率或模型内部状态。
|
|
12
|
+
- 默认策略在当前对话作用域与 Workspace 累积 12 个 Host Turn 时建议建立 checkpoint,在 24 个 Host Turn 时建议轮换到新对话;checkpoint 创建后的 8 个 Host Turn 内视为近期恢复边界。交接信息保持有界,只包含未完成 Task 头部、最新 checkpoint 元数据和 Git 摘要;Relay 与 Composite 显式 member 路径保持同样语义。
|
|
13
|
+
|
|
14
|
+
## [1.3.3] - 2026-10-15
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `open_workspace` 现在明确区分 Host 顶层 Core tool、`capabilityCatalog` 与 `capabilityFingerprint`:`minimal` / `full` 的核心工具仍由 Host 当前 `tools/list` snapshot 决定,不再允许仅因 fingerprint/catalog 未列出 `write`、`edit` 或 `bash` 就把 ForgeRelay 判断为只读;模型可见标签也改为明确的 Capability catalog 与 semantic feature fingerprint 表述。
|
|
19
|
+
|
|
7
20
|
## [1.3.2] - 2026-10-08
|
|
8
21
|
|
|
9
22
|
### Security
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Workspace Conversation Health
|
|
2
|
+
|
|
3
|
+
`workspace.conversation-health` is a low-frequency, read-only advisory capability for deciding when a long-lived Host conversation should checkpoint or rotate while durable Workspace state remains canonical.
|
|
4
|
+
|
|
5
|
+
## Policy
|
|
6
|
+
|
|
7
|
+
The current deterministic policy uses only ForgeRelay-observable state:
|
|
8
|
+
|
|
9
|
+
- recommend a checkpoint at 12 Host Turns when no recent persistent checkpoint exists;
|
|
10
|
+
- recommend conversation rotation at 24 Host Turns;
|
|
11
|
+
- treat a checkpoint as recent for 8 Host Turns after its creation.
|
|
12
|
+
|
|
13
|
+
These thresholds are policy constants, not estimates of model token usage, context-window utilization, memory pressure, or reasoning state.
|
|
14
|
+
|
|
15
|
+
The result exposes the policy thresholds, Host Turn counts, bounded active Task summaries, the latest persistent checkpoint when available, and bounded Git state for Git-backed Workspaces.
|
|
16
|
+
|
|
17
|
+
## Recommended actions
|
|
18
|
+
|
|
19
|
+
- `continue` — the current conversation is still within the advisory threshold.
|
|
20
|
+
- `checkpoint` — create a named `workspace.checkpoint` before continuing.
|
|
21
|
+
- `checkpoint-and-rotate` — create a fresh persistent checkpoint, then move to a fresh Host conversation.
|
|
22
|
+
- `rotate` — a recent checkpoint already exists; move to a fresh Host conversation and reopen the same Workspace.
|
|
23
|
+
|
|
24
|
+
The capability never creates a checkpoint, closes a Host conversation, stores the Host transcript, or forces rotation.
|
|
25
|
+
|
|
26
|
+
## Durable handoff
|
|
27
|
+
|
|
28
|
+
A fresh Host conversation should call `open_workspace` for the same canonical Workspace and resume from durable state. Workspace Tasks, persistent checkpoints, Git state, and Project status artifacts remain the source of truth; the previous Host transcript is not required for recovery.
|
|
29
|
+
|
|
30
|
+
Task handoff metadata is summary-only and excludes Task content. Checkpoint metadata is bounded and excludes patches or file contents.
|
|
31
|
+
|
|
32
|
+
## Relay and Composite Workspaces
|
|
33
|
+
|
|
34
|
+
For a relayed Workspace, the capability executes on the remote Execution ForgeRelay so Host Turn, Task, checkpoint, and Git observations come from the Workspace owner.
|
|
35
|
+
|
|
36
|
+
A Composite Workspace has no single filesystem conversation-health state. Pass an explicit `member`; the capability evaluates that member's Execution Workspace.
|
|
@@ -34,6 +34,16 @@ export class HostTurnStore {
|
|
|
34
34
|
limit 1`).get(conversationScopeId, workspaceId);
|
|
35
35
|
return row ? rowToTurn(row) : undefined;
|
|
36
36
|
}
|
|
37
|
+
countForConversationWorkspace(conversationScopeId, workspaceId, after) {
|
|
38
|
+
const row = after
|
|
39
|
+
? this.database.sqlite.prepare(`select count(*) as count
|
|
40
|
+
from activity_host_turns
|
|
41
|
+
where conversation_scope_id = ? and workspace_id = ? and created_at > ?`).get(conversationScopeId, workspaceId, after)
|
|
42
|
+
: this.database.sqlite.prepare(`select count(*) as count
|
|
43
|
+
from activity_host_turns
|
|
44
|
+
where conversation_scope_id = ? and workspace_id = ?`).get(conversationScopeId, workspaceId);
|
|
45
|
+
return Number(row?.count ?? 0);
|
|
46
|
+
}
|
|
37
47
|
deleteWorkspace(workspaceId) {
|
|
38
48
|
this.database.sqlite.prepare("delete from activity_host_turns where workspace_id = ?").run(workspaceId);
|
|
39
49
|
}
|
|
@@ -24,6 +24,12 @@ export class ActivityQueryService {
|
|
|
24
24
|
currentTurnId(conversationScopeId, workspaceId) {
|
|
25
25
|
return this.turns.current(conversationScopeId, workspaceId)?.turnId;
|
|
26
26
|
}
|
|
27
|
+
countTurns(conversationScopeId, workspaceId, after) {
|
|
28
|
+
return this.turns.countForConversationWorkspace(conversationScopeId, workspaceId, after);
|
|
29
|
+
}
|
|
30
|
+
turnWorkspaceId(turnId) {
|
|
31
|
+
return this.turns.get(turnId)?.workspaceId;
|
|
32
|
+
}
|
|
27
33
|
state(turnId, knownRevision) {
|
|
28
34
|
this.requireTurn(turnId);
|
|
29
35
|
const aggregate = this.audit.turnState(turnId);
|
package/dist/mcp/panel/app.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { access } from "node:fs/promises";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
@@ -14,11 +15,16 @@ export function createActivityPanelApp(config, fallbackRevision) {
|
|
|
14
15
|
const identity = currentIdentity(config, fallbackRevision);
|
|
15
16
|
const domain = new URL(config.publicBaseUrl).origin;
|
|
16
17
|
const csp = activityPanelCsp(config);
|
|
18
|
+
const openAiWidgetCsp = openAiCompatibilityCsp(csp);
|
|
17
19
|
return {
|
|
18
20
|
uri: identity.uri,
|
|
19
21
|
resourceMetadata: {
|
|
20
22
|
description: "ForgeRelay unified Workspace and Activity UI for one Host Turn.",
|
|
21
|
-
_meta: {
|
|
23
|
+
_meta: {
|
|
24
|
+
ui: { domain, csp },
|
|
25
|
+
"openai/widgetDomain": domain,
|
|
26
|
+
"openai/widgetCSP": openAiWidgetCsp,
|
|
27
|
+
},
|
|
22
28
|
},
|
|
23
29
|
toolMeta: config.widgets === "off"
|
|
24
30
|
? { _meta: {} }
|
|
@@ -28,6 +34,8 @@ export function createActivityPanelApp(config, fallbackRevision) {
|
|
|
28
34
|
resourceUri: identity.uri,
|
|
29
35
|
visibility: ["model", "app"],
|
|
30
36
|
},
|
|
37
|
+
"openai/outputTemplate": identity.uri,
|
|
38
|
+
"openai/widgetAccessible": true,
|
|
31
39
|
},
|
|
32
40
|
},
|
|
33
41
|
readResource: (requestedUri, transportSessionId) => readActivityPanelAppResource(config, fallbackRevision, requestedUri, transportSessionId),
|
|
@@ -70,11 +78,18 @@ function activityPanelCsp(config) {
|
|
|
70
78
|
connectDomains: publicOrigins,
|
|
71
79
|
};
|
|
72
80
|
}
|
|
81
|
+
function openAiCompatibilityCsp(csp) {
|
|
82
|
+
return {
|
|
83
|
+
resource_domains: csp.resourceDomains,
|
|
84
|
+
connect_domains: csp.connectDomains,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
73
87
|
async function readActivityPanelAppResource(config, fallbackRevision, requestedUri, transportSessionId) {
|
|
74
88
|
const identity = currentIdentity(config, fallbackRevision);
|
|
75
89
|
const entry = readActivityPanelAppManifestEntry(uiManifestUrl());
|
|
76
90
|
const domain = new URL(config.publicBaseUrl).origin;
|
|
77
91
|
const csp = activityPanelCsp(config);
|
|
92
|
+
const openAiWidgetCsp = openAiCompatibilityCsp(csp);
|
|
78
93
|
try {
|
|
79
94
|
await assertMcpAppAssets(entry);
|
|
80
95
|
const result = {
|
|
@@ -84,6 +99,10 @@ async function readActivityPanelAppResource(config, fallbackRevision, requestedU
|
|
|
84
99
|
text: activityPanelAppHtml(config, entry),
|
|
85
100
|
_meta: {
|
|
86
101
|
ui: { domain, csp },
|
|
102
|
+
// ChatGPT compatibility aliases remain useful for older/mobile hosts
|
|
103
|
+
// that have not fully converged on the MCP Apps standard metadata.
|
|
104
|
+
"openai/widgetDomain": domain,
|
|
105
|
+
"openai/widgetCSP": openAiWidgetCsp,
|
|
87
106
|
// MCP Apps defines these values under `_meta.ui`. Inspector 2.3.0
|
|
88
107
|
// still reads them directly from the content-item metadata, so mirror
|
|
89
108
|
// them until that installed-host compatibility gap is retired.
|
|
@@ -112,25 +131,34 @@ async function readActivityPanelAppResource(config, fallbackRevision, requestedU
|
|
|
112
131
|
function activityPanelAppHtml(config, entry) {
|
|
113
132
|
const baseUrl = `${config.publicBaseUrl.replace(/\/+$/, "")}/mcp-app-assets`;
|
|
114
133
|
const assetUrl = (assetPath) => `${baseUrl}/${assetPath.replace(/^\/+/, "")}`;
|
|
115
|
-
const
|
|
116
|
-
.
|
|
117
|
-
|
|
134
|
+
const entryDirectory = entry.file.includes("/")
|
|
135
|
+
? entry.file.slice(0, entry.file.lastIndexOf("/") + 1)
|
|
136
|
+
: "";
|
|
137
|
+
const styles = (entry.css ?? [])
|
|
138
|
+
.map((stylesheet) => readUiAssetText(stylesheet).replace(/<\/style/gi, "<\\/style"))
|
|
139
|
+
.join("\\n");
|
|
140
|
+
const script = readUiAssetText(entry.file)
|
|
141
|
+
.replace(/import\((["'`])\.\/([^"'`]+)\1\)/g, (_match, quote, relativePath) => `import(${quote}${assetUrl(`${entryDirectory}${relativePath}`)}${quote})`)
|
|
142
|
+
.replace(/<\/script/gi, "<\\/script");
|
|
118
143
|
return `<!doctype html>
|
|
119
144
|
<html lang="en">
|
|
120
145
|
<head>
|
|
121
146
|
<meta charset="UTF-8" />
|
|
122
147
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
123
148
|
<title>ForgeRelay Activity Panel</title>
|
|
124
|
-
<
|
|
125
|
-
${stylesheets}
|
|
149
|
+
<style>${styles}</style>
|
|
126
150
|
</head>
|
|
127
151
|
<body>
|
|
128
152
|
<main id="app" class="shell">
|
|
129
153
|
<section class="empty">Waiting for Activity Panel state.</section>
|
|
130
154
|
</main>
|
|
155
|
+
<script type="module">${script}</script>
|
|
131
156
|
</body>
|
|
132
157
|
</html>`;
|
|
133
158
|
}
|
|
159
|
+
function readUiAssetText(assetPath) {
|
|
160
|
+
return readFileSync(new URL(`../../../dist/ui/${assetPath}`, import.meta.url), "utf8");
|
|
161
|
+
}
|
|
134
162
|
async function assertMcpAppAssets(entry) {
|
|
135
163
|
const candidates = [entry.file, ...(entry.css ?? [])].map((assetPath) => new URL(`../../../dist/ui/${assetPath}`, import.meta.url));
|
|
136
164
|
for (const candidate of candidates)
|
|
@@ -4,7 +4,7 @@ export const ACTIVITY_PANEL_APP_MANIFEST_ENTRY = "activity-panel-app.html";
|
|
|
4
4
|
// Host caches are keyed by the ui:// resource URI, so the revision must cover
|
|
5
5
|
// both bundle assets and the HTML/resource contract that serves them. Bump this
|
|
6
6
|
// whenever either side changes so a Host never reuses stale UI bytes.
|
|
7
|
-
export const MCP_APP_RESOURCE_TEMPLATE_REVISION = "
|
|
7
|
+
export const MCP_APP_RESOURCE_TEMPLATE_REVISION = "7";
|
|
8
8
|
export function activityPanelAppUriForRevision(revision) {
|
|
9
9
|
return `ui://forgerelay/activity-panel-app-${encodeURIComponent(revision)}.html`;
|
|
10
10
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function createWorkspaceCheckpointCapabilityRuntime(store) {
|
|
2
|
+
return {
|
|
3
|
+
available: true,
|
|
4
|
+
run: async (input, context) => {
|
|
5
|
+
const root = context.workspaceRoot;
|
|
6
|
+
if (!root)
|
|
7
|
+
throw new Error("Workspace checkpoint requires a filesystem-backed Workspace.");
|
|
8
|
+
switch (input.operation) {
|
|
9
|
+
case "create":
|
|
10
|
+
return {
|
|
11
|
+
value: {
|
|
12
|
+
workspaceId: context.workspaceId,
|
|
13
|
+
checkpoint: await store.create(context.workspaceId, root, input.name),
|
|
14
|
+
ignoredFilesIncluded: false,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
case "list":
|
|
18
|
+
return { value: await store.list(context.workspaceId, root, input) };
|
|
19
|
+
case "inspect":
|
|
20
|
+
return { value: await store.inspect(context.workspaceId, root, input.checkpointId) };
|
|
21
|
+
case "restore.preflight":
|
|
22
|
+
return {
|
|
23
|
+
value: await store.preflightRestore(context.workspaceId, root, input.checkpointId),
|
|
24
|
+
};
|
|
25
|
+
case "restore":
|
|
26
|
+
return {
|
|
27
|
+
value: await store.restore(context.workspaceId, root, input.checkpointId, input.expectedCurrentSnapshot),
|
|
28
|
+
};
|
|
29
|
+
case "delete":
|
|
30
|
+
return { value: await store.delete(context.workspaceId, root, input.checkpointId) };
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -27,3 +27,20 @@ export const workspaceCheckpointInputSchema = z.discriminatedUnion("operation",
|
|
|
27
27
|
checkpointId: z.string().regex(/^cp_[a-f0-9]{10}$/),
|
|
28
28
|
}).strict(),
|
|
29
29
|
]);
|
|
30
|
+
export function workspaceCheckpointCapabilityDefinitions(dependency, availability) {
|
|
31
|
+
if (!dependency)
|
|
32
|
+
return [];
|
|
33
|
+
return [{
|
|
34
|
+
name: "workspace.checkpoint",
|
|
35
|
+
description: [
|
|
36
|
+
"Create, list, inspect, safely restore, or delete immutable Git-backed checkpoints",
|
|
37
|
+
"owned by the current persistent Workspace.",
|
|
38
|
+
].join(" "),
|
|
39
|
+
guideName: "workspace-checkpoints",
|
|
40
|
+
readGuideBeforeFirstUse: true,
|
|
41
|
+
batchPolicy: "unsupported",
|
|
42
|
+
inputSchema: workspaceCheckpointInputSchema,
|
|
43
|
+
availability: (context) => availability(context, dependency.available, dependency.unavailableReason),
|
|
44
|
+
run: async (input, context, options) => dependency.run(input, context, options),
|
|
45
|
+
}];
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const workspaceConversationHealthInputSchema = z.object({}).strict();
|
|
3
|
+
export function workspaceConversationHealthCapabilityDefinitions(dependency, availability) {
|
|
4
|
+
if (!dependency)
|
|
5
|
+
return [];
|
|
6
|
+
return [{
|
|
7
|
+
name: "workspace.conversation-health",
|
|
8
|
+
description: [
|
|
9
|
+
"Inspect deterministic conversation-aging signals and bounded durable handoff metadata",
|
|
10
|
+
"for the current Workspace.",
|
|
11
|
+
].join(" "),
|
|
12
|
+
guideName: "workspace-conversation-health",
|
|
13
|
+
readGuideBeforeFirstUse: true,
|
|
14
|
+
batchPolicy: "unsupported",
|
|
15
|
+
inputSchema: workspaceConversationHealthInputSchema,
|
|
16
|
+
availability: (context) => availability(context, dependency.available, dependency.unavailableReason),
|
|
17
|
+
run: async (_input, context, options) => dependency.run(context, options),
|
|
18
|
+
}];
|
|
19
|
+
}
|
|
@@ -58,6 +58,12 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
|
|
|
58
58
|
description: "Immutable persistent Git-backed checkpoints owned by the current Workspace.",
|
|
59
59
|
whenToRead: "Read before creating, listing, inspecting, or deleting Workspace checkpoints.",
|
|
60
60
|
},
|
|
61
|
+
{
|
|
62
|
+
name: "workspace-conversation-health",
|
|
63
|
+
directory: "workspace/workspace-conversation-health",
|
|
64
|
+
description: "Deterministic Host conversation aging guidance and bounded durable handoff metadata.",
|
|
65
|
+
whenToRead: "Read before using workspace.conversation-health for checkpoint or conversation-rotation guidance.",
|
|
66
|
+
},
|
|
61
67
|
{
|
|
62
68
|
name: "batch-execution",
|
|
63
69
|
description: "One-call execution of multiple independent ForgeRelay core operations.",
|
|
@@ -109,6 +115,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
|
|
|
109
115
|
"workspace.tasks",
|
|
110
116
|
"workspace.recovery",
|
|
111
117
|
"workspace.checkpoint",
|
|
118
|
+
"workspace.conversation-health",
|
|
112
119
|
];
|
|
113
120
|
if (config.toolMode !== "codex") {
|
|
114
121
|
capabilities.push("batch.execute");
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MAX_CODE_INTELLIGENCE_RESULT_LIMIT, } from "../../../lsp/code-intelligence-types.js";
|
|
3
3
|
import { batchExecuteInputSchema, } from "../../operations/batch/types.js";
|
|
4
|
-
import {
|
|
4
|
+
import { workspaceCheckpointCapabilityDefinitions, } from "./capabilities/workspace-checkpoint.js";
|
|
5
|
+
import { workspaceConversationHealthCapabilityDefinitions, } from "./capabilities/workspace-conversation-health.js";
|
|
5
6
|
import { externalMcpCapabilityDefinitions, } from "./capabilities/external-mcp.js";
|
|
6
7
|
export class CapabilityError extends Error {
|
|
7
8
|
code;
|
|
@@ -306,18 +307,8 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
306
307
|
run: async (input, context, options) => dependencies.workspaceRecovery.run(input, context, options),
|
|
307
308
|
}]
|
|
308
309
|
: []),
|
|
309
|
-
...(dependencies.workspaceCheckpoint
|
|
310
|
-
|
|
311
|
-
name: "workspace.checkpoint",
|
|
312
|
-
description: "Create, list, inspect, safely restore, or delete immutable Git-backed checkpoints owned by the current persistent Workspace.",
|
|
313
|
-
guideName: "workspace-checkpoints",
|
|
314
|
-
readGuideBeforeFirstUse: true,
|
|
315
|
-
batchPolicy: "unsupported",
|
|
316
|
-
inputSchema: workspaceCheckpointInputSchema,
|
|
317
|
-
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.workspaceCheckpoint?.available ?? false, dependencies.workspaceCheckpoint?.unavailableReason),
|
|
318
|
-
run: async (input, context, options) => dependencies.workspaceCheckpoint.run(input, context, options),
|
|
319
|
-
}]
|
|
320
|
-
: []),
|
|
310
|
+
...workspaceCheckpointCapabilityDefinitions(dependencies.workspaceCheckpoint, filesystemWorkspaceAvailability),
|
|
311
|
+
...workspaceConversationHealthCapabilityDefinitions(dependencies.workspaceConversationHealth, filesystemWorkspaceAvailability),
|
|
321
312
|
...(dependencies.workspaceTasks
|
|
322
313
|
? [{
|
|
323
314
|
name: "workspace.tasks",
|
|
@@ -510,6 +510,8 @@ export function createOperationRuntime(options) {
|
|
|
510
510
|
sessionId: context.sessionId,
|
|
511
511
|
batch: context.batch,
|
|
512
512
|
activityId: activityContext.activityId,
|
|
513
|
+
turnId: activityContext.turnId,
|
|
514
|
+
conversationScopeId: context.conversationScopeId,
|
|
513
515
|
});
|
|
514
516
|
changedPaths = execution.changedPaths ?? [];
|
|
515
517
|
const transientContent = execution.content;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { rebaseConversationHealthAge, } from "../../../../workspaces/state/conversation-health.js";
|
|
2
|
+
import { textBlock } from "../../core/tool-support.js";
|
|
3
|
+
export function rebaseRemoteCompositeConversationHealth(response, compositeWorkspaceId, conversationScopeId, activityQueries) {
|
|
4
|
+
if (response.isError === true)
|
|
5
|
+
return response;
|
|
6
|
+
const structured = response.structuredContent;
|
|
7
|
+
if (!structured || typeof structured !== "object")
|
|
8
|
+
return response;
|
|
9
|
+
const result = structured.result;
|
|
10
|
+
if (!result || typeof result !== "object")
|
|
11
|
+
return response;
|
|
12
|
+
const health = result;
|
|
13
|
+
const latestCheckpoint = health.latestCheckpoint;
|
|
14
|
+
const hostTurnCount = activityQueries.countTurns(conversationScopeId, compositeWorkspaceId);
|
|
15
|
+
const turnsSinceLatestCheckpoint = latestCheckpoint
|
|
16
|
+
? activityQueries.countTurns(conversationScopeId, compositeWorkspaceId, latestCheckpoint.createdAt)
|
|
17
|
+
: hostTurnCount;
|
|
18
|
+
const rebased = rebaseConversationHealthAge(health, {
|
|
19
|
+
hostTurnCount,
|
|
20
|
+
turnsSinceLatestCheckpoint,
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
...response,
|
|
24
|
+
content: [textBlock(`Capability workspace.conversation-health completed.\n${JSON.stringify(rebased, null, 2)}`)],
|
|
25
|
+
structuredContent: {
|
|
26
|
+
...structured,
|
|
27
|
+
result: rebased,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -12,7 +12,7 @@ import { logToolCall, workspaceLogContext } from "../../core/tool-support.js";
|
|
|
12
12
|
import { buildExecutionShellContext } from "../../../server-instructions.js";
|
|
13
13
|
import { workspaceColorFor, workspaceIdentityName } from "../../../../workspace-colors.js";
|
|
14
14
|
export const workspaceTaskUsageInstruction = "Use workspace.tasks proactively for work that spans multiple steps or sessions: create or update Tasks to preserve useful next steps and current state, and read its summary when resuming relevant unfinished work. Do not query it mechanically on every open_workspace call.";
|
|
15
|
-
export const capabilityDiscoveryInstruction = "For capability describe/run, use only names present in capabilityCatalog. capabilityFingerprint.capabilities is a semantic feature fingerprint, not a callable registry; capabilityGuides are documentation descriptors, not callable capabilities.";
|
|
15
|
+
export const capabilityDiscoveryInstruction = "Core MCP tools are separate from capabilityCatalog and capabilityFingerprint; the Host's current tools/list snapshot determines which top-level tools can be called. In toolMode=minimal or full, ForgeRelay's regular core surface is open_workspace, capability, close_workspace, read, write, edit, rename, delete, and bash. Do not infer that write, edit, or bash are unavailable merely because they are absent from capabilityCatalog or capabilityFingerprint. If an expected core tool is missing from the Host tool snapshot, treat the Host MCP metadata as stale or incomplete rather than describing ForgeRelay as read-only. For capability describe/run, use only names present in capabilityCatalog. capabilityFingerprint.capabilities is a semantic feature fingerprint, not a callable registry; capabilityGuides are documentation descriptors, not callable capabilities.";
|
|
16
16
|
export async function presentLocalWorkspaceOpen(options, input, contextData) {
|
|
17
17
|
const { config, forgerelayVersion: FORGERELAY_VERSION, workspaces, workspaceTasks, reviewCheckpoints, capabilityRegistry, subagentProviders, hooks, rememberWorkspacePanelState, } = options;
|
|
18
18
|
const { path, workspaceId, mode, baseRef, targetBranch, newWorktree, newWorkspace, context } = input;
|
|
@@ -155,7 +155,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
|
|
|
155
155
|
? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
|
|
156
156
|
: undefined,
|
|
157
157
|
capabilityCatalog.length > 0
|
|
158
|
-
? `
|
|
158
|
+
? `Capability catalog entries: ${capabilityCatalog.map((entry) => entry.name).join(", ")}`
|
|
159
159
|
: undefined,
|
|
160
160
|
visibleCapabilityGuides.length > 0
|
|
161
161
|
? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
|
|
@@ -175,7 +175,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
|
|
|
175
175
|
staleWorkspaces.length > 0
|
|
176
176
|
? `This Workspace has been idle for more than 2 days: ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. It remains available to resume or explicitly close; do not clean it up automatically.`
|
|
177
177
|
: undefined,
|
|
178
|
-
`ForgeRelay ${capabilityFingerprint.version}
|
|
178
|
+
`ForgeRelay semantic feature fingerprint (${capabilityFingerprint.version}): ${capabilityFingerprint.capabilities.join(", ")}`,
|
|
179
179
|
instruction,
|
|
180
180
|
resourceUpdate?.text,
|
|
181
181
|
].filter(Boolean).join("\n"),
|
|
@@ -503,7 +503,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
|
|
|
503
503
|
? `Available skills: ${relayedSkills.map((skill) => String(skill.name ?? "")).filter(Boolean).join(", ")}`
|
|
504
504
|
: undefined,
|
|
505
505
|
relayedCapabilities.length > 0
|
|
506
|
-
? `
|
|
506
|
+
? `Capability catalog entries: ${relayedCapabilities.map((entry) => String(entry.name ?? "")).filter(Boolean).join(", ")}`
|
|
507
507
|
: undefined,
|
|
508
508
|
opened.instruction,
|
|
509
509
|
].filter(Boolean).join("\n");
|
|
@@ -4,6 +4,7 @@ import { mcpHandlerRequestContext } from "../../../request-context.js";
|
|
|
4
4
|
import { CapabilityError } from "../../core/capability-registry.js";
|
|
5
5
|
import { attachHookReports, runToolWithHooks } from "../../../hooks/hooks.js";
|
|
6
6
|
import { toolNames } from "../../../server-instructions.js";
|
|
7
|
+
import { rebaseRemoteCompositeConversationHealth } from "./conversation-health-relay.js";
|
|
7
8
|
import { formatAgentsPath } from "../../../../workspaces.js";
|
|
8
9
|
import { activityRelationFor, activityRequestFor, runActivityToolWithHooks, } from "../../core/activity-support.js";
|
|
9
10
|
import { capabilityContextFor, compositeCapabilityContext, workspaceHookInvocation, } from "../../core/capability-support.js";
|
|
@@ -176,12 +177,17 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
176
177
|
const executionWorkspaceId = target.executionWorkspaceId;
|
|
177
178
|
const executionContext = await prepareExecutionContext(target, requestContext);
|
|
178
179
|
if (remoteWorkspaces.has(executionWorkspaceId)) {
|
|
179
|
-
|
|
180
|
+
let response = await remoteWorkspaces.capability(executionWorkspaceId, {
|
|
180
181
|
name,
|
|
181
182
|
action,
|
|
182
183
|
...(capabilityArguments !== undefined ? { arguments: capabilityArguments } : {}),
|
|
183
184
|
...(file !== undefined ? { file } : {}),
|
|
184
185
|
}, hostScopeIdFor(requestContext));
|
|
186
|
+
if (action === "run"
|
|
187
|
+
&& name === "workspace.conversation-health"
|
|
188
|
+
&& target.compositeWorkspaceId) {
|
|
189
|
+
response = rebaseRemoteCompositeConversationHealth(response, target.compositeWorkspaceId, hostScopeIdFor(requestContext), activityQueries);
|
|
190
|
+
}
|
|
185
191
|
return action === "run" && name !== "workspace.tasks"
|
|
186
192
|
? presentSemanticWorkResult(response, target, hostScopeIdFor(requestContext))
|
|
187
193
|
: presentExecutionResult(response, target);
|
package/dist/server.js
CHANGED
|
@@ -30,6 +30,8 @@ import { formatPathForPrompt } from "./workspaces/resources/skills.js";
|
|
|
30
30
|
import { WorkspaceTaskReminderTracker } from "./workspaces/tasks/workspace-task-reminders.js";
|
|
31
31
|
import { WorkspaceTaskStore } from "./workspaces/tasks/workspace-tasks.js";
|
|
32
32
|
import { WorkspaceCheckpointStore } from "./workspaces/state/workspace-checkpoints.js";
|
|
33
|
+
import { inspectConversationHealth } from "./workspaces/state/conversation-health.js";
|
|
34
|
+
import { createWorkspaceCheckpointCapabilityRuntime, } from "./mcp/server/core/capabilities/workspace-checkpoint-runtime.js";
|
|
33
35
|
import { compactCompositeWorkspacePresentation, compactRelayedWorkspacePresentation, compactWorkspacePresentation, } from "./workspaces/presentation/workspace-presentation.js";
|
|
34
36
|
import { formatAgentsPath } from "./workspaces.js";
|
|
35
37
|
import { summarizeSubagentProfile } from "./subagents/profiles.js";
|
|
@@ -148,32 +150,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
148
150
|
value: await workspaces.runManagedWorktreeRecovery(context.workspaceId, input.operation),
|
|
149
151
|
}),
|
|
150
152
|
},
|
|
151
|
-
workspaceCheckpoint:
|
|
153
|
+
workspaceCheckpoint: createWorkspaceCheckpointCapabilityRuntime(workspaceCheckpoints),
|
|
154
|
+
workspaceConversationHealth: {
|
|
152
155
|
available: true,
|
|
153
|
-
run: async (
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
value: {
|
|
159
|
-
workspaceId: context.workspaceId,
|
|
160
|
-
checkpoint: await workspaceCheckpoints.create(context.workspaceId, root, input.name),
|
|
161
|
-
ignoredFilesIncluded: false,
|
|
162
|
-
},
|
|
163
|
-
};
|
|
164
|
-
case "list":
|
|
165
|
-
return { value: await workspaceCheckpoints.list(context.workspaceId, root, input) };
|
|
166
|
-
case "inspect":
|
|
167
|
-
return { value: await workspaceCheckpoints.inspect(context.workspaceId, root, input.checkpointId) };
|
|
168
|
-
case "restore.preflight":
|
|
169
|
-
return { value: await workspaceCheckpoints.preflightRestore(context.workspaceId, root, input.checkpointId) };
|
|
170
|
-
case "restore":
|
|
171
|
-
return {
|
|
172
|
-
value: await workspaceCheckpoints.restore(context.workspaceId, root, input.checkpointId, input.expectedCurrentSnapshot),
|
|
173
|
-
};
|
|
174
|
-
case "delete":
|
|
175
|
-
return { value: await workspaceCheckpoints.delete(context.workspaceId, root, input.checkpointId) };
|
|
156
|
+
run: async (context, runOptions) => {
|
|
157
|
+
const conversationScopeId = runOptions.conversationScopeId;
|
|
158
|
+
if (!conversationScopeId) {
|
|
159
|
+
throw new CapabilityError("capability_unavailable", "workspace.conversation-health requires a resolved Host conversation scope.");
|
|
176
160
|
}
|
|
161
|
+
return {
|
|
162
|
+
value: await inspectConversationHealth({ activityQueries, workspaceTasks, workspaceCheckpoints }, {
|
|
163
|
+
workspaceId: context.workspaceId,
|
|
164
|
+
workspaceRoot: requireCapabilityWorkspaceRoot(context),
|
|
165
|
+
conversationScopeId,
|
|
166
|
+
conversationWorkspaceId: runOptions.turnId
|
|
167
|
+
? activityQueries.turnWorkspaceId(runOptions.turnId) ?? context.workspaceId
|
|
168
|
+
: context.workspaceId,
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
177
171
|
},
|
|
178
172
|
},
|
|
179
173
|
workspaceTasks: {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { getGitEligibility, git } from "../git/git.js";
|
|
2
|
+
export const CONVERSATION_HEALTH_POLICY = {
|
|
3
|
+
checkpointTurnThreshold: 12,
|
|
4
|
+
rotationTurnThreshold: 24,
|
|
5
|
+
checkpointFreshTurnWindow: 8,
|
|
6
|
+
};
|
|
7
|
+
export async function inspectConversationHealth(dependencies, input) {
|
|
8
|
+
const conversationWorkspaceId = input.conversationWorkspaceId ?? input.workspaceId;
|
|
9
|
+
const hostTurnCount = dependencies.activityQueries.countTurns(input.conversationScopeId, conversationWorkspaceId);
|
|
10
|
+
const gitState = await inspectGitState(input.workspaceRoot);
|
|
11
|
+
const latestCheckpoint = gitState
|
|
12
|
+
? (await dependencies.workspaceCheckpoints.latest(input.workspaceId, input.workspaceRoot)).checkpoint
|
|
13
|
+
: undefined;
|
|
14
|
+
const turnsSinceLatestCheckpoint = latestCheckpoint
|
|
15
|
+
? dependencies.activityQueries.countTurns(input.conversationScopeId, conversationWorkspaceId, latestCheckpoint.createdAt)
|
|
16
|
+
: hostTurnCount;
|
|
17
|
+
const policyDecision = evaluateConversationHealth({
|
|
18
|
+
hostTurnCount,
|
|
19
|
+
turnsSinceLatestCheckpoint,
|
|
20
|
+
checkpointAvailable: Boolean(gitState),
|
|
21
|
+
checkpointPresent: Boolean(latestCheckpoint),
|
|
22
|
+
});
|
|
23
|
+
const taskHeaders = dependencies.workspaceTasks.inspectHeaders(input.workspaceId);
|
|
24
|
+
return {
|
|
25
|
+
workspaceId: input.workspaceId,
|
|
26
|
+
...policyDecision,
|
|
27
|
+
policy: CONVERSATION_HEALTH_POLICY,
|
|
28
|
+
metrics: {
|
|
29
|
+
hostTurnCount,
|
|
30
|
+
turnsSinceLatestCheckpoint,
|
|
31
|
+
},
|
|
32
|
+
...(latestCheckpoint ? { latestCheckpoint } : {}),
|
|
33
|
+
activeTasks: (taskHeaders?.lists ?? [])
|
|
34
|
+
.filter((list) => list.state === "active")
|
|
35
|
+
.flatMap((list) => list.tasks.flatMap((task) => task.status === "completed"
|
|
36
|
+
? []
|
|
37
|
+
: [{
|
|
38
|
+
listId: list.id,
|
|
39
|
+
listName: list.name,
|
|
40
|
+
id: task.id,
|
|
41
|
+
status: task.status,
|
|
42
|
+
subject: task.subject,
|
|
43
|
+
}]))
|
|
44
|
+
.slice(0, 20),
|
|
45
|
+
...(gitState ? { git: gitState } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function evaluateConversationHealth(input) {
|
|
49
|
+
const checkpointRecent = input.checkpointPresent
|
|
50
|
+
&& input.turnsSinceLatestCheckpoint
|
|
51
|
+
<= CONVERSATION_HEALTH_POLICY.checkpointFreshTurnWindow;
|
|
52
|
+
if (input.hostTurnCount >= CONVERSATION_HEALTH_POLICY.rotationTurnThreshold) {
|
|
53
|
+
if (!input.checkpointAvailable) {
|
|
54
|
+
return {
|
|
55
|
+
status: "rotation-recommended",
|
|
56
|
+
reasons: [
|
|
57
|
+
"host_turn_count_rotation_threshold",
|
|
58
|
+
"checkpoint_unavailable",
|
|
59
|
+
],
|
|
60
|
+
recommendedAction: "rotate",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
status: "rotation-recommended",
|
|
65
|
+
reasons: [
|
|
66
|
+
"host_turn_count_rotation_threshold",
|
|
67
|
+
checkpointRecent ? "checkpoint_recent" : "checkpoint_stale",
|
|
68
|
+
],
|
|
69
|
+
recommendedAction: checkpointRecent ? "rotate" : "checkpoint-and-rotate",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (input.checkpointAvailable
|
|
73
|
+
&& input.hostTurnCount >= CONVERSATION_HEALTH_POLICY.checkpointTurnThreshold
|
|
74
|
+
&& !checkpointRecent) {
|
|
75
|
+
return {
|
|
76
|
+
status: "checkpoint-recommended",
|
|
77
|
+
reasons: [
|
|
78
|
+
"host_turn_count_checkpoint_threshold",
|
|
79
|
+
input.checkpointPresent ? "checkpoint_stale" : "checkpoint_missing",
|
|
80
|
+
],
|
|
81
|
+
recommendedAction: "checkpoint",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
status: "healthy",
|
|
86
|
+
reasons: checkpointRecent ? ["checkpoint_recent"] : [],
|
|
87
|
+
recommendedAction: "continue",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function rebaseConversationHealthAge(result, input) {
|
|
91
|
+
return {
|
|
92
|
+
...result,
|
|
93
|
+
...evaluateConversationHealth({
|
|
94
|
+
...input,
|
|
95
|
+
checkpointAvailable: result.git !== undefined,
|
|
96
|
+
checkpointPresent: result.latestCheckpoint !== undefined,
|
|
97
|
+
}),
|
|
98
|
+
policy: CONVERSATION_HEALTH_POLICY,
|
|
99
|
+
metrics: {
|
|
100
|
+
hostTurnCount: input.hostTurnCount,
|
|
101
|
+
turnsSinceLatestCheckpoint: input.turnsSinceLatestCheckpoint,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async function inspectGitState(workspaceRoot) {
|
|
106
|
+
const eligibility = await getGitEligibility(workspaceRoot);
|
|
107
|
+
if (!eligibility.ok || !eligibility.gitRoot)
|
|
108
|
+
return undefined;
|
|
109
|
+
const [headResult, branchResult, statusResult] = await Promise.all([
|
|
110
|
+
git(eligibility.gitRoot, ["rev-parse", "HEAD"]),
|
|
111
|
+
git(eligibility.gitRoot, ["branch", "--show-current"]),
|
|
112
|
+
git(eligibility.gitRoot, ["status", "--porcelain=v1", "--untracked-files=normal"], { maxBuffer: 2 * 1024 * 1024 }),
|
|
113
|
+
]);
|
|
114
|
+
const statusLines = statusResult.stdout
|
|
115
|
+
.split("\n")
|
|
116
|
+
.filter((line) => line.length > 0);
|
|
117
|
+
const branch = branchResult.stdout.trim();
|
|
118
|
+
return {
|
|
119
|
+
head: headResult.stdout.trim(),
|
|
120
|
+
...(branch ? { branch } : {}),
|
|
121
|
+
dirty: statusLines.length > 0,
|
|
122
|
+
changedFiles: statusLines.length,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -107,6 +107,21 @@ export class WorkspaceCheckpointStore {
|
|
|
107
107
|
ignoredFilesIncluded: false,
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
|
+
async latest(workspaceId, workspaceRoot) {
|
|
111
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
112
|
+
const repository = await resolveRepository(workspaceRoot);
|
|
113
|
+
const state = this.tryReadState(id);
|
|
114
|
+
if (state)
|
|
115
|
+
await assertSameRepository(state.gitCommonDir, repository.gitCommonDir, id);
|
|
116
|
+
const checkpoint = state?.checkpoints.at(-1);
|
|
117
|
+
if (checkpoint)
|
|
118
|
+
await assertCheckpointRef(state.gitCommonDir, id, checkpoint);
|
|
119
|
+
return {
|
|
120
|
+
workspaceId: id,
|
|
121
|
+
...(checkpoint ? { checkpoint: cloneCheckpoint(checkpoint) } : {}),
|
|
122
|
+
ignoredFilesIncluded: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
110
125
|
async inspect(workspaceId, workspaceRoot, checkpointId) {
|
|
111
126
|
const id = normalizeWorkspaceId(workspaceId);
|
|
112
127
|
const cpId = normalizeCheckpointId(checkpointId);
|
|
@@ -91,6 +91,11 @@ export class WorkspaceTaskStore {
|
|
|
91
91
|
readHeaders(workspaceId, listId) {
|
|
92
92
|
return taskHeaders(this.ensureWorkspace(workspaceId), listId);
|
|
93
93
|
}
|
|
94
|
+
inspectHeaders(workspaceId) {
|
|
95
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
96
|
+
const loaded = this.tryReadState(id);
|
|
97
|
+
return loaded ? taskHeaders(snapshot(loaded.state, loaded.fingerprint)) : undefined;
|
|
98
|
+
}
|
|
94
99
|
readTaskDetail(workspaceId, listId, taskId) {
|
|
95
100
|
return taskDetail(this.ensureWorkspace(workspaceId), listId, taskId);
|
|
96
101
|
}
|
package/docs/configuration.md
CHANGED
|
@@ -453,7 +453,12 @@ version, tool mode, and stable semantic capability names. The fingerprint also
|
|
|
453
453
|
reports enabled optional domains such as subagent profile discovery, native
|
|
454
454
|
artifact download, MCP App UI, or aggregate `review.changes` when those
|
|
455
455
|
features are actually available; it remains a semantic summary rather than a
|
|
456
|
-
copy of `tools/list`.
|
|
456
|
+
copy of `tools/list`. Neither `capabilityFingerprint` nor `capabilityCatalog`
|
|
457
|
+
is evidence that an omitted Core tool is unavailable. The Host's current
|
|
458
|
+
`tools/list` snapshot is authoritative for top-level callability; in
|
|
459
|
+
`minimal` and `full`, the expected regular Core surface remains
|
|
460
|
+
`open_workspace`, `capability`, `close_workspace`, `read`, `write`,
|
|
461
|
+
`edit`, `rename`, `delete`, and `bash`.
|
|
457
462
|
|
|
458
463
|
Bootstrap responses also return `capabilityGuides`, which are compact descriptors
|
|
459
464
|
for built-in ForgeRelay documentation that the Agent can explicitly load with
|
|
@@ -466,11 +471,14 @@ context.
|
|
|
466
471
|
|
|
467
472
|
There is no separate progressive-disclosure configuration switch. Capability
|
|
468
473
|
Guide discovery is built in, while optional low-frequency actions are advertised
|
|
469
|
-
through the workspace Capability catalog instead of adding top-level tools. If
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
+
through the workspace Capability catalog instead of adding top-level tools. If an
|
|
475
|
+
expected Core tool is missing from the Host's current tool snapshot, or the
|
|
476
|
+
fingerprint reports a semantic feature that the Host metadata does not reflect,
|
|
477
|
+
treat that as stale or incomplete Host MCP metadata: reconnect/refresh the
|
|
478
|
+
integration or use a Host context that reloads `tools/list`. Do not describe
|
|
479
|
+
ForgeRelay as read-only merely because `write`, `edit`, or `bash` is absent
|
|
480
|
+
from the fingerprint or Capability catalog. The ForgeRelay process cannot force
|
|
481
|
+
a Host to invalidate its cached schema.
|
|
474
482
|
|
|
475
483
|
### LSP code intelligence
|
|
476
484
|
|