@akira-tl/forgerelay 1.3.3 → 1.3.5
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/oauth/oauth-store.js +3 -1
- 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-tools.js +7 -1
- package/dist/runtime/config/config.js +1 -5
- 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/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.5] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- OAuth Dynamic Client Registration 默认不再绑定 ChatGPT 等厂商 redirect host 名单:现在接受任意 HTTPS callback 以及 localhost / 127.0.0.1 / ::1 的 HTTP/HTTPS loopback callback;显式 `FORGERELAY_OAUTH_ALLOWED_REDIRECT_HOSTS` 继续作为可选收紧策略。Gemini Connected Apps 等标准 MCP Host 可以通过 `/register` 获取 ForgeRelay 签发的 `forgerelay-<uuid>` client ID,同时未知 client ID、未注册 redirect URI、PKCE 与 Owner OAuth 校验保持不变。
|
|
12
|
+
|
|
13
|
+
## [1.3.4] - 2026-10-29
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- 新增只读 `workspace.conversation-health` Capability:仅使用 ForgeRelay 可观察的 Host Turn、持久 Workspace checkpoint、Task 与 Git 状态给出 `healthy`、`checkpoint-recommended` 或 `rotation-recommended` 建议,不推测 Host token、上下文窗口利用率或模型内部状态。
|
|
18
|
+
- 默认策略在当前对话作用域与 Workspace 累积 12 个 Host Turn 时建议建立 checkpoint,在 24 个 Host Turn 时建议轮换到新对话;checkpoint 创建后的 8 个 Host Turn 内视为近期恢复边界。交接信息保持有界,只包含未完成 Task 头部、最新 checkpoint 元数据和 Git 摘要;Relay 与 Composite 显式 member 路径保持同样语义。
|
|
19
|
+
|
|
7
20
|
## [1.3.3] - 2026-10-15
|
|
8
21
|
|
|
9
22
|
### Fixed
|
|
@@ -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);
|
|
@@ -12,7 +12,9 @@ function redirectHostAllowed(redirectUri, allowedHosts) {
|
|
|
12
12
|
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
|
|
13
13
|
if (loopback)
|
|
14
14
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
15
|
-
|
|
15
|
+
if (parsed.protocol !== "https:")
|
|
16
|
+
return false;
|
|
17
|
+
return allowedHosts.length === 0 || allowedHosts.includes(parsed.hostname);
|
|
16
18
|
}
|
|
17
19
|
export class SqliteOAuthStore {
|
|
18
20
|
database;
|
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
|
+
}
|
|
@@ -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);
|
|
@@ -184,11 +184,7 @@ function parseOAuthConfig(env, ownerToken) {
|
|
|
184
184
|
accessTokenTtlSeconds: parsePositiveInteger(productEnv(env, "OAUTH_ACCESS_TOKEN_TTL_SECONDS"), DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS, "FORGERELAY_OAUTH_ACCESS_TOKEN_TTL_SECONDS"),
|
|
185
185
|
refreshTokenTtlSeconds: parsePositiveInteger(productEnv(env, "OAUTH_REFRESH_TOKEN_TTL_SECONDS"), DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS, "FORGERELAY_OAUTH_REFRESH_TOKEN_TTL_SECONDS"),
|
|
186
186
|
scopes: parseStringList(productEnv(env, "OAUTH_SCOPES"), ["forgerelay"]),
|
|
187
|
-
allowedRedirectHosts: parseStringList(productEnv(env, "OAUTH_ALLOWED_REDIRECT_HOSTS"), [
|
|
188
|
-
"chatgpt.com",
|
|
189
|
-
"localhost",
|
|
190
|
-
"127.0.0.1",
|
|
191
|
-
]),
|
|
187
|
+
allowedRedirectHosts: parseStringList(productEnv(env, "OAUTH_ALLOWED_REDIRECT_HOSTS"), []),
|
|
192
188
|
};
|
|
193
189
|
}
|
|
194
190
|
function defaultStateDir() {
|
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
|
}
|