@swarmclawai/swarmclaw 1.5.63 → 1.5.64
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/README.md +17 -0
- package/package.json +2 -2
- package/src/app/api/chats/[id]/clear/route.ts +7 -3
- package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
- package/src/app/api/chats/[id]/compact/route.ts +72 -0
- package/src/app/api/chats/[id]/context-status/route.ts +21 -0
- package/src/app/api/chats/clear-route.test.ts +121 -0
- package/src/app/api/chats/compact-route.test.ts +70 -0
- package/src/app/api/chats/context-status-route.test.ts +68 -0
- package/src/app/api/mcp-servers/[id]/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
- package/src/cli/index.js +5 -1
- package/src/cli/spec.js +4 -1
- package/src/components/chat/chat-area.tsx +62 -6
- package/src/components/chat/chat-header.tsx +13 -1
- package/src/components/chat/context-meter-badge.tsx +227 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
- package/src/components/mcp-servers/registry-browser.tsx +224 -0
- package/src/lib/chat/chats.ts +37 -1
- package/src/lib/server/chats/chat-session-service.ts +75 -0
- package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
- package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
- package/src/lib/server/mcp-connection-pool.test.ts +98 -0
- package/src/lib/server/mcp-connection-pool.ts +134 -0
- package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
- package/src/lib/server/mcp-gateway-runtime.ts +138 -0
- package/src/lib/server/session-tools/index.ts +83 -15
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/types/agent.ts +1 -0
- package/src/types/misc.ts +7 -0
package/README.md
CHANGED
|
@@ -399,6 +399,23 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.5.64 Highlights
|
|
403
|
+
|
|
404
|
+
Two themes this release. First, **context-window management reaches the chat UI**: a live token-usage meter in every chat header, a one-click LLM-backed compaction that keeps the session alive without nuking history, and a redesigned clear flow with a 30-second undo that restores both transcripts and CLI resume IDs. Second, **MCP token spend is now controllable**: per-server `alwaysExpose` policy, per-agent eager-tool overrides, an in-session `mcp_tool_search` promoter, a long-lived connection pool, a token-cost endpoint per server, and a built-in browser for the public SwarmDock MCP registry.
|
|
405
|
+
|
|
406
|
+
- **Context meter in the chat header.** New `ContextMeterBadge` (`src/components/chat/context-meter-badge.tsx`) renders a live chip showing `N% · Mk` next to the chat title, driven by `GET /api/chats/:id/context-status`. Color thresholds at 70% (amber) and 90% (red). Clicking the chip opens a popover with the full breakdown (used / remaining / messages) plus Compact and Clear buttons. The button row explicitly states: *"Long-term memory, skills, and facts are preserved. Clear only affects this chat transcript."* — so users stop fearing Clear.
|
|
407
|
+
- **User-invokable `/compact` via the popover.** New `POST /api/chats/:id/compact` runs `summarizeAndCompact` with the session's own provider/model via `buildChatModel` as the summarizer. The existing hierarchical-summary pipeline in `context-manager.ts` does the work: tool failures, file ops, and adaptive chunking are all preserved. Accepts `keepLastN` in the body (2-200, default 10). Returns `status: 'no_action' | 'compacted'` plus counts. The popover gates the button below 3 messages so users don't waste LLM calls on trivially short transcripts.
|
|
408
|
+
- **Clear with 30-second undo.** `POST /api/chats/:id/clear` now returns `{ cleared, undoToken, expiresAt }`, and a new `POST /api/chats/:id/clear/undo` restores the snapshot. The undo snapshot (messages + every CLI session ID including `claudeSessionId`, `codexThreadId`, `opencodeSessionId`, `opencodeWebSessionId`, `geminiSessionId`, `copilotSessionId`, `droidSessionId`, `cursorSessionId`, `qwenSessionId`, `acpSessionId`, and `delegateResumeIds`) lives in an HMR-safe in-memory store (`src/lib/server/chats/clear-undo-snapshots.ts`) with a 30-second TTL, 200-entry cap, session-scoped lookups, and single-use tokens. The chat UI wires this to a sonner toast with an Undo action; restoring fires a "Chat restored." confirmation toast.
|
|
409
|
+
- **`alwaysExpose` policy for MCP servers** (`McpServerConfig.alwaysExpose: boolean | string[]`, default `true` for back-compat). Set `false` on a chatty server (e.g. a Playwright MCP with 40 tools that cost thousands of tokens per turn) and the agent binds nothing up front — it can still discover and promote specific tools via the new `mcp_tool_search` meta-tool. Set an allowlist `['query_resources', 'fetch_url']` to eagerly bind a curated subset.
|
|
410
|
+
- **Per-agent `mcpEagerTools` override** (`Agent.mcpEagerTools?: string[]`) lets you force-expose specific tool names for a specific agent regardless of the server's `alwaysExpose`. Precedence: per-agent allowlist > server `alwaysExpose` > session promotions.
|
|
411
|
+
- **`mcp_tool_search` meta-tool** (`src/lib/server/mcp-gateway-runtime.ts`). When a server's tools are lazy, the agent gets a single `mcp_tool_search({ query, limit? })` tool that searches the process-wide discovery cache (bare name substring + description keywords) and promotes matches for the current session. The next turn binds the promoted names for real. `SessionToolPromoter` state is keyed by session ID and HMR-safe. Behavior mirrors `@swarmclawai/mcp-gateway`'s router so users who split MCP fan-out across SwarmClaw and the gateway get consistent semantics.
|
|
412
|
+
- **Long-lived MCP connection pool** (`src/lib/server/mcp-connection-pool.ts`). A single client/transport per server lives for the process lifetime instead of reconnecting every turn. Config-fingerprint tracking rotates stale entries automatically; the `/test` endpoint evicts explicitly so a config change takes effect immediately. Saves ~100-500ms × (servers × turns) per chat. HMR-safe via `hmrSingleton` so dev reloads don't leak child processes.
|
|
413
|
+
- **Token-cost discovery endpoint** (`GET /api/mcp-servers/:id/tools-info`). Connects, lists tools, and reports per-tool schema tokens plus aggregates — using the same `chars / 3.5` formula as `@swarmclawai/mcp-gateway` so numbers line up side by side. Surfaces inside `mcp-server-list.tsx` so you can see which server is the costliest before an agent even runs.
|
|
414
|
+
- **SwarmDock MCP Registry browser** (`src/components/mcp-servers/registry-browser.tsx`). Opens from the New MCP Server sheet and browses the public registry at `https://swarmdock-api.onrender.com/api/v1/mcp/servers`. Selecting a server populates the form with its recommended install command — one-click discovery without leaving SwarmClaw. A new `MCP Gateway (local)` preset is also bundled so users can bootstrap `@swarmclawai/mcp-gateway` in one tap.
|
|
415
|
+
- **4 new CLI commands.** `swarmclaw chats context-status <id>`, `swarmclaw chats compact <id>`, `swarmclaw chats clear-undo <id>`, and the existing `chats clear` now returns the undo token so CLI scripts can build their own clear+undo workflows.
|
|
416
|
+
- **Back-compat normalization.** Existing MCP servers load with `alwaysExpose: true` (historical behavior — every tool bound every turn) via `storage-normalization.ts`. No user action required to upgrade.
|
|
417
|
+
- **Full regression coverage.** New tests: `clear-undo-snapshots.test.ts` (5 cases — TTL, single-use, session isolation, CLI-id preservation, expiry sweep), `clear-route.test.ts` (clear → undo → double-undo 404 → missing-session 404 round-trip), `compact-route.test.ts` (no-action path + 404), `context-status-route.test.ts`, plus `mcp-connection-pool.test.ts` and `mcp-gateway-runtime.test.ts`. `test:runtime` runs 100 tests across 13 suites.
|
|
418
|
+
|
|
402
419
|
### v1.5.63 Highlights
|
|
403
420
|
|
|
404
421
|
Chatroom fix from @borislavnnikolov: CLI-backed agents (codex-cli, copilot-cli, gemini-cli, and the rest of the `NON_LANGGRAPH_PROVIDER_IDS` set) now work correctly as chatroom members instead of falling through a LangGraph path they cannot run. With the execution path fixed, the worker-only membership blocks are lifted too, so any non-trashed agent can be added to a room.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.64",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
|
|
81
81
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
|
|
82
82
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
83
|
-
"test:runtime": "tsx --test src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
|
|
83
|
+
"test:runtime": "tsx --test src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
|
|
84
84
|
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
|
|
85
85
|
"test:e2e": "tsx .workbench/browser-e2e/run.ts",
|
|
86
86
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import {
|
|
2
|
+
import { clearChatMessagesWithUndo } from '@/lib/server/chats/chat-session-service'
|
|
3
3
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
4
4
|
|
|
5
5
|
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
6
6
|
const { id } = await params
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const result = clearChatMessagesWithUndo(id)
|
|
8
|
+
if (!result.ok) {
|
|
9
|
+
if (result.status === 404) return notFound()
|
|
10
|
+
return NextResponse.json(result.payload, { status: result.status })
|
|
11
|
+
}
|
|
12
|
+
return NextResponse.json(result.payload)
|
|
9
13
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { restoreChatFromUndoToken } from '@/lib/server/chats/chat-session-service'
|
|
4
|
+
import { badRequest, notFound } from '@/lib/server/collection-helpers'
|
|
5
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
6
|
+
|
|
7
|
+
const BodySchema = z.object({
|
|
8
|
+
undoToken: z.string().min(1),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
12
|
+
const { id } = await params
|
|
13
|
+
const parsed = await safeParseBody(req, BodySchema)
|
|
14
|
+
if (parsed.error) return parsed.error
|
|
15
|
+
const token = parsed.data.undoToken.trim()
|
|
16
|
+
if (!token) return badRequest('undoToken is required')
|
|
17
|
+
const result = restoreChatFromUndoToken(id, token)
|
|
18
|
+
if (!result.ok) {
|
|
19
|
+
if (result.status === 404) return notFound(result.payload.error)
|
|
20
|
+
return NextResponse.json(result.payload, { status: result.status })
|
|
21
|
+
}
|
|
22
|
+
return NextResponse.json(result.payload)
|
|
23
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { HumanMessage } from '@langchain/core/messages'
|
|
4
|
+
import { getSession } from '@/lib/server/sessions/session-repository'
|
|
5
|
+
import { getMessages, replaceAllMessages } from '@/lib/server/messages/message-repository'
|
|
6
|
+
import { summarizeAndCompact, type LLMSummarizer } from '@/lib/server/context-manager'
|
|
7
|
+
import { buildChatModel } from '@/lib/server/build-llm'
|
|
8
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
9
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
10
|
+
import { errorMessage } from '@/lib/shared-utils'
|
|
11
|
+
|
|
12
|
+
const BodySchema = z.object({
|
|
13
|
+
keepLastN: z.number().int().min(2).max(200).optional(),
|
|
14
|
+
}).partial()
|
|
15
|
+
|
|
16
|
+
const DEFAULT_KEEP_LAST_N = 10
|
|
17
|
+
|
|
18
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
19
|
+
const { id } = await params
|
|
20
|
+
const session = getSession(id)
|
|
21
|
+
if (!session) return notFound()
|
|
22
|
+
|
|
23
|
+
const parsed = await safeParseBody(req, BodySchema)
|
|
24
|
+
if (parsed.error) return parsed.error
|
|
25
|
+
const keepLastN = Math.max(2, Math.min(parsed.data.keepLastN ?? DEFAULT_KEEP_LAST_N, 200))
|
|
26
|
+
|
|
27
|
+
const messages = getMessages(id)
|
|
28
|
+
if (messages.length <= keepLastN) {
|
|
29
|
+
return NextResponse.json({
|
|
30
|
+
status: 'no_action',
|
|
31
|
+
messageCount: messages.length,
|
|
32
|
+
keepLastN,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const generateSummary: LLMSummarizer = async (prompt) => {
|
|
37
|
+
const llm = buildChatModel({
|
|
38
|
+
provider: session.provider,
|
|
39
|
+
model: session.model,
|
|
40
|
+
apiKey: null,
|
|
41
|
+
credentialId: session.credentialId ?? null,
|
|
42
|
+
apiEndpoint: session.apiEndpoint ?? null,
|
|
43
|
+
})
|
|
44
|
+
const res = await llm.invoke([new HumanMessage(prompt)])
|
|
45
|
+
return typeof res.content === 'string' ? res.content : ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const result = await summarizeAndCompact({
|
|
50
|
+
messages,
|
|
51
|
+
keepLastN,
|
|
52
|
+
agentId: session.agentId ?? null,
|
|
53
|
+
sessionId: id,
|
|
54
|
+
provider: session.provider as string,
|
|
55
|
+
model: session.model as string,
|
|
56
|
+
generateSummary,
|
|
57
|
+
})
|
|
58
|
+
replaceAllMessages(id, result.messages)
|
|
59
|
+
return NextResponse.json({
|
|
60
|
+
status: 'compacted',
|
|
61
|
+
prunedCount: result.prunedCount,
|
|
62
|
+
memoriesStored: result.memoriesStored,
|
|
63
|
+
summaryAdded: result.summaryAdded,
|
|
64
|
+
messageCount: result.messages.length,
|
|
65
|
+
})
|
|
66
|
+
} catch (err: unknown) {
|
|
67
|
+
return NextResponse.json(
|
|
68
|
+
{ error: `Compaction failed: ${errorMessage(err)}` },
|
|
69
|
+
{ status: 500 },
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { getSession } from '@/lib/server/sessions/session-repository'
|
|
3
|
+
import { getMessages } from '@/lib/server/messages/message-repository'
|
|
4
|
+
import { getContextStatus } from '@/lib/server/context-manager'
|
|
5
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
6
|
+
|
|
7
|
+
const SYSTEM_PROMPT_TOKEN_ESTIMATE = 2000
|
|
8
|
+
|
|
9
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
10
|
+
const { id } = await params
|
|
11
|
+
const session = getSession(id)
|
|
12
|
+
if (!session) return notFound()
|
|
13
|
+
const messages = getMessages(id)
|
|
14
|
+
const status = getContextStatus(
|
|
15
|
+
messages,
|
|
16
|
+
SYSTEM_PROMPT_TOKEN_ESTIMATE,
|
|
17
|
+
session.provider as string,
|
|
18
|
+
session.model as string,
|
|
19
|
+
)
|
|
20
|
+
return NextResponse.json(status)
|
|
21
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('chat clear + undo round-trip restores messages and CLI session IDs', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
clearStatus: number
|
|
9
|
+
clearPayload: { cleared: number; undoToken: string }
|
|
10
|
+
postClearCount: number
|
|
11
|
+
postClearClaudeSessionId: string | null
|
|
12
|
+
postClearOpencodeWebSessionId: string | null
|
|
13
|
+
undoStatus: number
|
|
14
|
+
undoPayload: { restored: number }
|
|
15
|
+
postUndoCount: number
|
|
16
|
+
postUndoClaudeSessionId: string | null
|
|
17
|
+
postUndoOpencodeWebSessionId: string | null
|
|
18
|
+
undoTwiceStatus: number
|
|
19
|
+
undoTwicePayload: { error?: string }
|
|
20
|
+
missingSessionStatus: number
|
|
21
|
+
}>(`
|
|
22
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
23
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
24
|
+
const clearRouteMod = await import('./src/app/api/chats/[id]/clear/route')
|
|
25
|
+
const undoRouteMod = await import('./src/app/api/chats/[id]/clear/undo/route')
|
|
26
|
+
const storage = storageMod.default || storageMod
|
|
27
|
+
const repo = repoMod.default || repoMod
|
|
28
|
+
const clearRoute = clearRouteMod.default || clearRouteMod
|
|
29
|
+
const undoRoute = undoRouteMod.default || undoRouteMod
|
|
30
|
+
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
storage.saveSessions({
|
|
33
|
+
sess_clear_1: {
|
|
34
|
+
id: 'sess_clear_1',
|
|
35
|
+
name: 'Clear test',
|
|
36
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
37
|
+
user: 'tester',
|
|
38
|
+
provider: 'openai',
|
|
39
|
+
model: 'gpt-5',
|
|
40
|
+
claudeSessionId: 'cs_preserved_abc',
|
|
41
|
+
codexThreadId: null,
|
|
42
|
+
opencodeWebSessionId: 'owb_preserved_xyz',
|
|
43
|
+
messages: [],
|
|
44
|
+
createdAt: now,
|
|
45
|
+
lastActiveAt: now,
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
repo.appendMessage('sess_clear_1', { role: 'user', text: 'first', time: now })
|
|
50
|
+
repo.appendMessage('sess_clear_1', { role: 'assistant', text: 'reply', time: now + 1 })
|
|
51
|
+
repo.appendMessage('sess_clear_1', { role: 'user', text: 'second', time: now + 2 })
|
|
52
|
+
|
|
53
|
+
const clearResponse = await clearRoute.POST(
|
|
54
|
+
new Request('http://local/api/chats/sess_clear_1/clear', { method: 'POST' }),
|
|
55
|
+
{ params: Promise.resolve({ id: 'sess_clear_1' }) },
|
|
56
|
+
)
|
|
57
|
+
const clearPayload = await clearResponse.json()
|
|
58
|
+
|
|
59
|
+
const postClearCount = repo.getMessages('sess_clear_1').length
|
|
60
|
+
const postClearSession = storage.loadSession('sess_clear_1')
|
|
61
|
+
|
|
62
|
+
const undoResponse = await undoRoute.POST(
|
|
63
|
+
new Request('http://local/api/chats/sess_clear_1/clear/undo', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'content-type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ undoToken: clearPayload.undoToken }),
|
|
67
|
+
}),
|
|
68
|
+
{ params: Promise.resolve({ id: 'sess_clear_1' }) },
|
|
69
|
+
)
|
|
70
|
+
const undoPayload = await undoResponse.json()
|
|
71
|
+
|
|
72
|
+
const postUndoCount = repo.getMessages('sess_clear_1').length
|
|
73
|
+
const postUndoSession = storage.loadSession('sess_clear_1')
|
|
74
|
+
|
|
75
|
+
const undoTwiceResponse = await undoRoute.POST(
|
|
76
|
+
new Request('http://local/api/chats/sess_clear_1/clear/undo', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'content-type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ undoToken: clearPayload.undoToken }),
|
|
80
|
+
}),
|
|
81
|
+
{ params: Promise.resolve({ id: 'sess_clear_1' }) },
|
|
82
|
+
)
|
|
83
|
+
const undoTwicePayload = await undoTwiceResponse.json()
|
|
84
|
+
|
|
85
|
+
const missingResponse = await clearRoute.POST(
|
|
86
|
+
new Request('http://local/api/chats/not_a_real_session/clear', { method: 'POST' }),
|
|
87
|
+
{ params: Promise.resolve({ id: 'not_a_real_session' }) },
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
console.log(JSON.stringify({
|
|
91
|
+
clearStatus: clearResponse.status,
|
|
92
|
+
clearPayload: { cleared: clearPayload.cleared, undoToken: clearPayload.undoToken },
|
|
93
|
+
postClearCount,
|
|
94
|
+
postClearClaudeSessionId: postClearSession?.claudeSessionId ?? null,
|
|
95
|
+
postClearOpencodeWebSessionId: postClearSession?.opencodeWebSessionId ?? null,
|
|
96
|
+
undoStatus: undoResponse.status,
|
|
97
|
+
undoPayload,
|
|
98
|
+
postUndoCount,
|
|
99
|
+
postUndoClaudeSessionId: postUndoSession?.claudeSessionId ?? null,
|
|
100
|
+
postUndoOpencodeWebSessionId: postUndoSession?.opencodeWebSessionId ?? null,
|
|
101
|
+
undoTwiceStatus: undoTwiceResponse.status,
|
|
102
|
+
undoTwicePayload,
|
|
103
|
+
missingSessionStatus: missingResponse.status,
|
|
104
|
+
}))
|
|
105
|
+
`, { prefix: 'swarmclaw-clear-undo-route-' })
|
|
106
|
+
|
|
107
|
+
assert.equal(output.clearStatus, 200)
|
|
108
|
+
assert.equal(output.clearPayload.cleared, 3)
|
|
109
|
+
assert.match(output.clearPayload.undoToken, /^undo_/)
|
|
110
|
+
assert.equal(output.postClearCount, 0)
|
|
111
|
+
assert.equal(output.postClearClaudeSessionId, null, 'CLI session should be nulled after clear')
|
|
112
|
+
assert.equal(output.postClearOpencodeWebSessionId, null, 'opencode-web session should be nulled after clear')
|
|
113
|
+
assert.equal(output.undoStatus, 200)
|
|
114
|
+
assert.equal(output.undoPayload.restored, 3)
|
|
115
|
+
assert.equal(output.postUndoCount, 3)
|
|
116
|
+
assert.equal(output.postUndoClaudeSessionId, 'cs_preserved_abc', 'CLI session ID should be restored by undo')
|
|
117
|
+
assert.equal(output.postUndoOpencodeWebSessionId, 'owb_preserved_xyz', 'opencode-web session ID should be restored by undo')
|
|
118
|
+
assert.equal(output.undoTwiceStatus, 404, 'undo token should be single-use')
|
|
119
|
+
assert.ok(output.undoTwicePayload.error)
|
|
120
|
+
assert.equal(output.missingSessionStatus, 404)
|
|
121
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('POST /api/chats/[id]/compact returns no_action when transcript is smaller than keepLastN', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
payload: { status?: string; messageCount?: number; keepLastN?: number }
|
|
10
|
+
missingStatus: number
|
|
11
|
+
}>(`
|
|
12
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
13
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
14
|
+
const routeMod = await import('./src/app/api/chats/[id]/compact/route')
|
|
15
|
+
const storage = storageMod.default || storageMod
|
|
16
|
+
const repo = repoMod.default || repoMod
|
|
17
|
+
const route = routeMod.default || routeMod
|
|
18
|
+
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
storage.saveSessions({
|
|
21
|
+
sess_compact_1: {
|
|
22
|
+
id: 'sess_compact_1',
|
|
23
|
+
name: 'Compact test',
|
|
24
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
25
|
+
user: 'tester',
|
|
26
|
+
provider: 'openai',
|
|
27
|
+
model: 'gpt-4o-mini',
|
|
28
|
+
claudeSessionId: null,
|
|
29
|
+
messages: [],
|
|
30
|
+
createdAt: now,
|
|
31
|
+
lastActiveAt: now,
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Only 2 messages — smaller than default keepLastN (10), so compact should no-op.
|
|
36
|
+
repo.appendMessage('sess_compact_1', { role: 'user', text: 'hi', time: now })
|
|
37
|
+
repo.appendMessage('sess_compact_1', { role: 'assistant', text: 'hello', time: now + 1 })
|
|
38
|
+
|
|
39
|
+
const response = await route.POST(
|
|
40
|
+
new Request('http://local/api/chats/sess_compact_1/compact', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({}),
|
|
44
|
+
}),
|
|
45
|
+
{ params: Promise.resolve({ id: 'sess_compact_1' }) },
|
|
46
|
+
)
|
|
47
|
+
const payload = await response.json()
|
|
48
|
+
|
|
49
|
+
const missingResponse = await route.POST(
|
|
50
|
+
new Request('http://local/api/chats/missing/compact', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({}),
|
|
54
|
+
}),
|
|
55
|
+
{ params: Promise.resolve({ id: 'missing' }) },
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console.log(JSON.stringify({
|
|
59
|
+
status: response.status,
|
|
60
|
+
payload,
|
|
61
|
+
missingStatus: missingResponse.status,
|
|
62
|
+
}))
|
|
63
|
+
`, { prefix: 'swarmclaw-compact-route-' })
|
|
64
|
+
|
|
65
|
+
assert.equal(output.status, 200)
|
|
66
|
+
assert.equal(output.payload.status, 'no_action')
|
|
67
|
+
assert.equal(output.payload.messageCount, 2)
|
|
68
|
+
assert.equal(output.payload.keepLastN, 10)
|
|
69
|
+
assert.equal(output.missingStatus, 404)
|
|
70
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('GET /api/chats/[id]/context-status returns token usage summary', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
hasContextWindow: boolean
|
|
10
|
+
percentUsed: number
|
|
11
|
+
messageCount: number
|
|
12
|
+
strategy: string
|
|
13
|
+
missingStatus: number
|
|
14
|
+
}>(`
|
|
15
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
16
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
17
|
+
const routeMod = await import('./src/app/api/chats/[id]/context-status/route')
|
|
18
|
+
const storage = storageMod.default || storageMod
|
|
19
|
+
const repo = repoMod.default || repoMod
|
|
20
|
+
const route = routeMod.default || routeMod
|
|
21
|
+
|
|
22
|
+
const now = Date.now()
|
|
23
|
+
storage.saveSessions({
|
|
24
|
+
sess_ctx_1: {
|
|
25
|
+
id: 'sess_ctx_1',
|
|
26
|
+
name: 'Context status test',
|
|
27
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
28
|
+
user: 'tester',
|
|
29
|
+
provider: 'openai',
|
|
30
|
+
model: 'gpt-4o-mini',
|
|
31
|
+
claudeSessionId: null,
|
|
32
|
+
messages: [],
|
|
33
|
+
createdAt: now,
|
|
34
|
+
lastActiveAt: now,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
repo.appendMessage('sess_ctx_1', { role: 'user', text: 'hello world', time: now })
|
|
39
|
+
repo.appendMessage('sess_ctx_1', { role: 'assistant', text: 'hi there, how can I help?', time: now + 1 })
|
|
40
|
+
|
|
41
|
+
const response = await route.GET(
|
|
42
|
+
new Request('http://local/api/chats/sess_ctx_1/context-status'),
|
|
43
|
+
{ params: Promise.resolve({ id: 'sess_ctx_1' }) },
|
|
44
|
+
)
|
|
45
|
+
const payload = await response.json()
|
|
46
|
+
|
|
47
|
+
const missingResponse = await route.GET(
|
|
48
|
+
new Request('http://local/api/chats/missing/context-status'),
|
|
49
|
+
{ params: Promise.resolve({ id: 'missing' }) },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
console.log(JSON.stringify({
|
|
53
|
+
status: response.status,
|
|
54
|
+
hasContextWindow: typeof payload.contextWindow === 'number' && payload.contextWindow > 0,
|
|
55
|
+
percentUsed: payload.percentUsed,
|
|
56
|
+
messageCount: payload.messageCount,
|
|
57
|
+
strategy: payload.strategy,
|
|
58
|
+
missingStatus: missingResponse.status,
|
|
59
|
+
}))
|
|
60
|
+
`, { prefix: 'swarmclaw-context-status-route-' })
|
|
61
|
+
|
|
62
|
+
assert.equal(output.status, 200)
|
|
63
|
+
assert.equal(output.hasContextWindow, true)
|
|
64
|
+
assert.ok(output.percentUsed >= 0 && output.percentUsed <= 100)
|
|
65
|
+
assert.equal(output.messageCount, 2)
|
|
66
|
+
assert.ok(['ok', 'warning', 'critical'].includes(output.strategy))
|
|
67
|
+
assert.equal(output.missingStatus, 404)
|
|
68
|
+
})
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
3
3
|
import { loadMcpServers, saveMcpServers, deleteMcpServer } from '@/lib/server/storage'
|
|
4
4
|
import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
|
|
5
|
+
import { evictMcpClient } from '@/lib/server/mcp-connection-pool'
|
|
5
6
|
|
|
6
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
8
|
const ops: CollectionOps<any> = { load: loadMcpServers, save: saveMcpServers, deleteFn: deleteMcpServer }
|
|
@@ -21,11 +22,15 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
21
22
|
...server, ...body, id, updatedAt: Date.now(),
|
|
22
23
|
}))
|
|
23
24
|
if (!result) return notFound()
|
|
25
|
+
// Connection pool caches by config fingerprint; evicting here is defense in
|
|
26
|
+
// depth — getOrConnectMcpClient also detects fingerprint mismatches.
|
|
27
|
+
await evictMcpClient(id)
|
|
24
28
|
return NextResponse.json(result)
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
28
32
|
const { id } = await params
|
|
29
33
|
if (!deleteItem(ops, id)) return notFound()
|
|
34
|
+
await evictMcpClient(id)
|
|
30
35
|
return NextResponse.json({ deleted: id })
|
|
31
36
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { loadMcpServers } from '@/lib/server/storage'
|
|
3
3
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
4
4
|
import { connectMcpServer, mcpToolsToLangChain, disconnectMcpServer } from '@/lib/server/mcp-client'
|
|
5
|
+
import { evictMcpClient } from '@/lib/server/mcp-connection-pool'
|
|
5
6
|
import { errorMessage } from '@/lib/shared-utils'
|
|
6
7
|
|
|
7
8
|
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
@@ -10,6 +11,10 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
|
|
|
10
11
|
const server = servers[id]
|
|
11
12
|
if (!server) return notFound()
|
|
12
13
|
|
|
14
|
+
// Force a fresh connection for the test — if a pooled client is in a weird
|
|
15
|
+
// state, the test button is the user's signal to rebuild it.
|
|
16
|
+
await evictMcpClient(id)
|
|
17
|
+
|
|
13
18
|
try {
|
|
14
19
|
const { client, transport } = await connectMcpServer(server)
|
|
15
20
|
const tools = await mcpToolsToLangChain(client, server.name)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { loadMcpServers } from '@/lib/server/storage'
|
|
3
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
4
|
+
import { connectMcpServer, disconnectMcpServer } from '@/lib/server/mcp-client'
|
|
5
|
+
|
|
6
|
+
// Tokenizer-free estimate — same formula as @swarmclawai/mcp-gateway's
|
|
7
|
+
// tokens.ts so the two numbers line up when users compare side-by-side.
|
|
8
|
+
const CHARS_PER_TOKEN = 3.5
|
|
9
|
+
|
|
10
|
+
function estimateToolTokens(tool: {
|
|
11
|
+
name: string
|
|
12
|
+
description?: string
|
|
13
|
+
inputSchema?: unknown
|
|
14
|
+
}): number {
|
|
15
|
+
const json = JSON.stringify({
|
|
16
|
+
name: tool.name,
|
|
17
|
+
description: tool.description ?? '',
|
|
18
|
+
inputSchema: tool.inputSchema ?? {},
|
|
19
|
+
})
|
|
20
|
+
return Math.ceil(json.length / CHARS_PER_TOKEN)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Discovery + token-cost endpoint for an MCP server. Connects, lists tools,
|
|
25
|
+
* estimates per-tool schema tokens, and returns aggregate totals — including
|
|
26
|
+
* how many tokens would actually be bound given the server's current
|
|
27
|
+
* alwaysExpose policy. The MCP servers UI uses this to render the token-cost
|
|
28
|
+
* badge on each card and the per-tool checklist in the allow-list editor.
|
|
29
|
+
*/
|
|
30
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
31
|
+
const { id } = await params
|
|
32
|
+
const servers = loadMcpServers()
|
|
33
|
+
const config = servers[id]
|
|
34
|
+
if (!config) return notFound()
|
|
35
|
+
|
|
36
|
+
let client: Awaited<ReturnType<typeof connectMcpServer>>['client'] | null = null
|
|
37
|
+
let transport: Awaited<ReturnType<typeof connectMcpServer>>['transport'] | null = null
|
|
38
|
+
try {
|
|
39
|
+
const conn = await connectMcpServer(config)
|
|
40
|
+
client = conn.client
|
|
41
|
+
transport = conn.transport
|
|
42
|
+
const { tools } = await client.listTools()
|
|
43
|
+
const detailed = tools.map((t: { name: string; description?: string; inputSchema?: unknown }) => ({
|
|
44
|
+
name: t.name,
|
|
45
|
+
description: t.description ?? '',
|
|
46
|
+
inputSchema: t.inputSchema ?? {},
|
|
47
|
+
tokens: estimateToolTokens(t),
|
|
48
|
+
}))
|
|
49
|
+
const totalTokens = detailed.reduce((n: number, t: { tokens: number }) => n + t.tokens, 0)
|
|
50
|
+
const mode = config.alwaysExpose === undefined ? true : config.alwaysExpose
|
|
51
|
+
const exposedTokens =
|
|
52
|
+
mode === true
|
|
53
|
+
? totalTokens
|
|
54
|
+
: mode === false
|
|
55
|
+
? 0
|
|
56
|
+
: detailed
|
|
57
|
+
.filter((t: { name: string }) => (mode as string[]).includes(t.name))
|
|
58
|
+
.reduce((n: number, t: { tokens: number }) => n + t.tokens, 0)
|
|
59
|
+
return NextResponse.json({
|
|
60
|
+
tools: detailed,
|
|
61
|
+
totalTokens,
|
|
62
|
+
exposedTokens,
|
|
63
|
+
alwaysExpose: mode,
|
|
64
|
+
})
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
return NextResponse.json(
|
|
67
|
+
{ error: err instanceof Error ? err.message : 'MCP connection failed' },
|
|
68
|
+
{ status: 502 },
|
|
69
|
+
)
|
|
70
|
+
} finally {
|
|
71
|
+
if (client && transport) {
|
|
72
|
+
await disconnectMcpServer(client, transport)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -367,6 +367,7 @@ const COMMAND_GROUPS = [
|
|
|
367
367
|
cmd('delete', 'DELETE', '/mcp-servers/:id', 'Delete MCP server'),
|
|
368
368
|
cmd('test', 'POST', '/mcp-servers/:id/test', 'Test MCP server connection'),
|
|
369
369
|
cmd('tools', 'GET', '/mcp-servers/:id/tools', 'List tools available on an MCP server'),
|
|
370
|
+
cmd('tools-info', 'GET', '/mcp-servers/:id/tools-info', 'List tools with token-cost estimates and exposure status'),
|
|
370
371
|
cmd('conformance', 'POST', '/mcp-servers/:id/conformance', 'Run MCP conformance checks for a server', { expectsJsonBody: true }),
|
|
371
372
|
cmd('invoke', 'POST', '/mcp-servers/:id/invoke', 'Invoke an MCP tool on a server', { expectsJsonBody: true }),
|
|
372
373
|
],
|
|
@@ -585,7 +586,10 @@ const COMMAND_GROUPS = [
|
|
|
585
586
|
responseType: 'sse',
|
|
586
587
|
}),
|
|
587
588
|
cmd('stop', 'POST', '/chats/:id/stop', 'Stop chat run(s)'),
|
|
588
|
-
cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages'),
|
|
589
|
+
cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages (returns undoToken with a 30s TTL)'),
|
|
590
|
+
cmd('clear-undo', 'POST', '/chats/:id/clear/undo', 'Restore a cleared chat via its undoToken', { expectsJsonBody: true }),
|
|
591
|
+
cmd('compact', 'POST', '/chats/:id/compact', 'Summarize and compact chat history (accepts optional keepLastN)', { expectsJsonBody: true }),
|
|
592
|
+
cmd('context-status', 'GET', '/chats/:id/context-status', 'Report token usage and context-window status for a chat'),
|
|
589
593
|
cmd('browser-status', 'GET', '/chats/:id/browser', 'Check browser status'),
|
|
590
594
|
cmd('browser-close', 'DELETE', '/chats/:id/browser', 'Close browser'),
|
|
591
595
|
cmd('mailbox', 'GET', '/chats/:id/mailbox', 'List chat mailbox envelopes'),
|
package/src/cli/spec.js
CHANGED
|
@@ -435,7 +435,10 @@ const COMMAND_GROUPS = {
|
|
|
435
435
|
'edit-resend': { description: 'Edit and resend from a specific message index', method: 'POST', path: '/chats/:id/edit-resend', params: ['id'] },
|
|
436
436
|
chat: { description: 'Send chat message (SSE stream)', method: 'POST', path: '/chats/:id/chat', params: ['id'], stream: true, waitable: true },
|
|
437
437
|
stop: { description: 'Cancel active/running chat work', method: 'POST', path: '/chats/:id/stop', params: ['id'] },
|
|
438
|
-
clear: { description: 'Clear chat history', method: 'POST', path: '/chats/:id/clear', params: ['id'] },
|
|
438
|
+
clear: { description: 'Clear chat history (returns undoToken with 30s TTL)', method: 'POST', path: '/chats/:id/clear', params: ['id'] },
|
|
439
|
+
'clear-undo': { description: 'Restore a cleared chat via its undoToken', method: 'POST', path: '/chats/:id/clear/undo', params: ['id'] },
|
|
440
|
+
compact: { description: 'Summarize and compact chat history', method: 'POST', path: '/chats/:id/compact', params: ['id'] },
|
|
441
|
+
'context-status': { description: 'Report token usage and context-window status', method: 'GET', path: '/chats/:id/context-status', params: ['id'] },
|
|
439
442
|
mailbox: { description: 'List mailbox envelopes for a chat', method: 'GET', path: '/chats/:id/mailbox', params: ['id'] },
|
|
440
443
|
'mailbox-action': { description: 'Send/ack/clear mailbox envelopes', method: 'POST', path: '/chats/:id/mailbox', params: ['id'] },
|
|
441
444
|
queue: { description: 'List queued follow-up turns for a chat', method: 'GET', path: '/chats/:id/queue', params: ['id'] },
|