zhicun2-mcp 0.3.2

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 ADDED
@@ -0,0 +1,59 @@
1
+ # zhicun2-mcp
2
+
3
+ 智存 2.0 的 MCP 服务:AI 通过 `zhi` 工具在扩展侧边栏与用户交互,并阻塞等待回复。
4
+
5
+ 需配合 [智存 2.0 Cursor 扩展](https://github.com/zhicun/zhicun2.0)(`.vsix`)使用。
6
+
7
+ ## 安装(给最终用户)
8
+
9
+ ### 1. 安装扩展
10
+
11
+ Cursor → **扩展** → `...` → **从 VSIX 安装…** → 选择 `zhicun2-x.x.x.vsix`
12
+
13
+ ### 2. 配置 MCP
14
+
15
+ 在 `~/.cursor/mcp.json`(Windows: `C:\Users\<用户名>\.cursor\mcp.json`)或项目 `.cursor/mcp.json` 中加入:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "zhicun2": {
21
+ "command": "npx",
22
+ "args": ["-y", "zhicun2-mcp"],
23
+ "env": {
24
+ "ZHICUN2_WORKSPACE": "${workspaceFolder}"
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ ### 3. 重载 Cursor
32
+
33
+ **Developer: Reload Window**,确认 MCP 面板中 `zhicun2` 已连接。
34
+
35
+ ### 4. 验证
36
+
37
+ 让 Agent 调用 `ping`,应返回 `✅ 智存扩展在线`。
38
+
39
+ ## 环境变量(可选)
40
+
41
+ | 变量 | 说明 |
42
+ |------|------|
43
+ | `ZHICUN2_WORKSPACE` | 当前工作区路径,多窗口时路由到对应扩展(推荐 `${workspaceFolder}`) |
44
+ | `ZHICUN2_PORT` | 强制指定扩展 HTTP 端口 |
45
+ | `ZHICUN2_WINDOW_ID` | 强制指定 Cursor 窗口 ID |
46
+
47
+ ## 工具
48
+
49
+ - `zhi` — 向侧边栏发消息并阻塞等待用户回复
50
+ - `ping` — 检测扩展是否在线
51
+ - `mem_read` / `mem_write` — 读写工作区 `.zhicun2/memory/`
52
+
53
+ ## 开发
54
+
55
+ ```bash
56
+ npm install
57
+ npm run build
58
+ npm publish # 需 npm 登录
59
+ ```
@@ -0,0 +1,24 @@
1
+ import { resolveBaseUrl, resolveBaseUrlAsync } from "./registry.js";
2
+ export interface PromptPayload {
3
+ id: string;
4
+ session_id: string;
5
+ session_label?: string;
6
+ message: string;
7
+ predefined_options?: string[];
8
+ is_markdown?: boolean;
9
+ }
10
+ export interface WaitResult {
11
+ status: "answered" | "pending" | "timeout" | "error";
12
+ response?: string;
13
+ error?: string;
14
+ }
15
+ export declare function submitPrompt(baseUrl: string, payload: PromptPayload): Promise<void>;
16
+ export declare function waitForResponse(baseUrl: string, sessionId: string, id: string, timeoutMs: number): Promise<WaitResult>;
17
+ export declare function readMemory(baseUrl: string, key: string): Promise<unknown>;
18
+ export declare function writeMemory(baseUrl: string, key: string, value: unknown): Promise<void>;
19
+ export declare function waitForUserReply(payload: PromptPayload, options?: {
20
+ pollTimeoutMs?: number;
21
+ maxWaitMs?: number;
22
+ baseUrl?: string;
23
+ }): Promise<string>;
24
+ export { resolveBaseUrl, resolveBaseUrlAsync };
package/dist/bridge.js ADDED
@@ -0,0 +1,62 @@
1
+ import { resolveBaseUrl, resolveBaseUrlAsync } from "./registry.js";
2
+ export async function submitPrompt(baseUrl, payload) {
3
+ const res = await fetch(`${baseUrl}/api/prompt`, {
4
+ method: "POST",
5
+ headers: { "Content-Type": "application/json" },
6
+ body: JSON.stringify(payload),
7
+ });
8
+ if (!res.ok) {
9
+ const text = await res.text();
10
+ throw new Error(`提交对话失败 (${res.status}): ${text}`);
11
+ }
12
+ }
13
+ export async function waitForResponse(baseUrl, sessionId, id, timeoutMs) {
14
+ const qs = new URLSearchParams({
15
+ timeout: String(timeoutMs),
16
+ session_id: sessionId,
17
+ });
18
+ const res = await fetch(`${baseUrl}/api/prompt/${encodeURIComponent(id)}/wait?${qs.toString()}`, { method: "GET" });
19
+ if (!res.ok) {
20
+ const text = await res.text();
21
+ return { status: "error", error: `等待回复失败 (${res.status}): ${text}` };
22
+ }
23
+ return (await res.json());
24
+ }
25
+ export async function readMemory(baseUrl, key) {
26
+ const res = await fetch(`${baseUrl}/api/memory/${encodeURIComponent(key)}`);
27
+ if (!res.ok) {
28
+ const text = await res.text();
29
+ throw new Error(`读取记忆失败 (${res.status}): ${text}`);
30
+ }
31
+ const data = (await res.json());
32
+ return data.found ? data.value : undefined;
33
+ }
34
+ export async function writeMemory(baseUrl, key, value) {
35
+ const res = await fetch(`${baseUrl}/api/memory/${encodeURIComponent(key)}`, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ value }),
39
+ });
40
+ if (!res.ok) {
41
+ const text = await res.text();
42
+ throw new Error(`写入记忆失败 (${res.status}): ${text}`);
43
+ }
44
+ }
45
+ export async function waitForUserReply(payload, options) {
46
+ const baseUrl = options?.baseUrl ?? resolveBaseUrl();
47
+ const pollTimeoutMs = options?.pollTimeoutMs ?? 30000;
48
+ const maxWaitMs = options?.maxWaitMs ?? 24 * 60 * 60 * 1000;
49
+ const deadline = Date.now() + maxWaitMs;
50
+ await submitPrompt(baseUrl, payload);
51
+ while (Date.now() < deadline) {
52
+ const result = await waitForResponse(baseUrl, payload.session_id, payload.id, pollTimeoutMs);
53
+ if (result.status === "answered" && result.response !== undefined) {
54
+ return result.response;
55
+ }
56
+ if (result.status === "error") {
57
+ throw new Error(result.error ?? "未知错误");
58
+ }
59
+ }
60
+ throw new Error("等待用户回复超时");
61
+ }
62
+ export { resolveBaseUrl, resolveBaseUrlAsync };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { randomUUID } from "crypto";
6
+ import { waitForUserReply, readMemory, writeMemory, resolveBaseUrlAsync } from "./bridge.js";
7
+ import { normalizeSessionId } from "./session-id.js";
8
+ const server = new Server({ name: "zhicun2", version: "0.3.2" }, { capabilities: { tools: {} } });
9
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
10
+ tools: [
11
+ {
12
+ name: "zhi",
13
+ description: "【必读 zhicun2-interaction 规则】智存交互工具:向指定 session 的用户展示消息并阻塞等待回复。禁止在 Cursor 聊天中做确认/提问。同一 Agent 对话线程必须使用稳定的 session_id(格式:项目名/任务名)。完成任何请求前必须调用。",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ message: {
18
+ type: "string",
19
+ description: "展示给用户的消息",
20
+ },
21
+ session_id: {
22
+ type: "string",
23
+ description: "会话隔离 ID。同一对话线程保持不变,如「格力app/Unpack」「zhicun2/架构」",
24
+ },
25
+ session_label: {
26
+ type: "string",
27
+ description: "侧边栏 Tab 显示名(可选,首次创建 session 时生效)",
28
+ },
29
+ predefined_options: {
30
+ type: "array",
31
+ items: { type: "string" },
32
+ description: "预定义选项(可选)",
33
+ },
34
+ is_markdown: {
35
+ type: "boolean",
36
+ description: "message 是否为 Markdown,默认 true",
37
+ },
38
+ },
39
+ required: ["message", "session_id"],
40
+ },
41
+ },
42
+ {
43
+ name: "mem_read",
44
+ description: "读取工作区 .zhicun2/memory/ 下的持久化记忆(JSON)",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ key: {
49
+ type: "string",
50
+ description: "记忆键名,如 preferences、user_notes",
51
+ },
52
+ },
53
+ required: ["key"],
54
+ },
55
+ },
56
+ {
57
+ name: "mem_write",
58
+ description: "写入工作区 .zhicun2/memory/ 下的持久化记忆(JSON)",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ key: {
63
+ type: "string",
64
+ description: "记忆键名",
65
+ },
66
+ value: {
67
+ description: "任意 JSON 可序列化值",
68
+ },
69
+ },
70
+ required: ["key", "value"],
71
+ },
72
+ },
73
+ {
74
+ name: "ping",
75
+ description: "检测智存扩展 HTTP 服务是否在线,返回窗口与会话信息",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {},
79
+ },
80
+ },
81
+ ],
82
+ }));
83
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
84
+ const { name, arguments: args = {} } = request.params;
85
+ if (name === "ping") {
86
+ const { baseUrl, matched, allInstances } = await resolveBaseUrlAsync();
87
+ try {
88
+ const res = await fetch(`${baseUrl}/api/health`);
89
+ const data = (await res.json());
90
+ const instanceLines = allInstances
91
+ .map((i) => ` · ${i.windowId.slice(0, 8)} :${i.port} [${i.workspaceFolders.join(", ") || "无工作区"}]`)
92
+ .join("\n");
93
+ return {
94
+ content: [
95
+ {
96
+ type: "text",
97
+ text: data.ok
98
+ ? `✅ 智存扩展在线 (${baseUrl})\n` +
99
+ `窗口: ${data.windowId ?? "?"}\n` +
100
+ `工作区: ${data.workspaceRoot ?? "?"}\n` +
101
+ `活跃会话: ${data.sessionCount ?? 0}\n` +
102
+ `路由命中: ${matched ? matched.windowId.slice(0, 8) + " :" + matched.port : "默认/legacy"}\n` +
103
+ `存活实例 (${allInstances.length}):\n${instanceLines || " (无)"}`
104
+ : `⚠️ 扩展响应异常 (${baseUrl})`,
105
+ },
106
+ ],
107
+ };
108
+ }
109
+ catch (err) {
110
+ return {
111
+ content: [
112
+ {
113
+ type: "text",
114
+ text: `❌ 无法连接智存扩展 (${baseUrl})\n` +
115
+ `请确认对应 Cursor 窗口已加载扩展。\n` +
116
+ `存活实例: ${allInstances.length}\n` +
117
+ `${String(err)}`,
118
+ },
119
+ ],
120
+ isError: true,
121
+ };
122
+ }
123
+ }
124
+ if (name === "mem_read") {
125
+ const key = String(args.key ?? "").trim();
126
+ if (!key) {
127
+ return { content: [{ type: "text", text: "key 不能为空" }], isError: true };
128
+ }
129
+ const { baseUrl } = await resolveBaseUrlAsync();
130
+ try {
131
+ const value = await readMemory(baseUrl, key);
132
+ if (value === undefined) {
133
+ return { content: [{ type: "text", text: `key "${key}" 不存在` }] };
134
+ }
135
+ return {
136
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
137
+ };
138
+ }
139
+ catch (err) {
140
+ return {
141
+ content: [{ type: "text", text: `❌ 读取失败: ${String(err)}` }],
142
+ isError: true,
143
+ };
144
+ }
145
+ }
146
+ if (name === "mem_write") {
147
+ const key = String(args.key ?? "").trim();
148
+ if (!key) {
149
+ return { content: [{ type: "text", text: "key 不能为空" }], isError: true };
150
+ }
151
+ const { baseUrl } = await resolveBaseUrlAsync();
152
+ try {
153
+ await writeMemory(baseUrl, key, args.value);
154
+ return { content: [{ type: "text", text: `✅ 已写入 key "${key}"` }] };
155
+ }
156
+ catch (err) {
157
+ return {
158
+ content: [{ type: "text", text: `❌ 写入失败: ${String(err)}` }],
159
+ isError: true,
160
+ };
161
+ }
162
+ }
163
+ if (name === "zhi") {
164
+ const message = String(args.message ?? "");
165
+ const sessionId = normalizeSessionId(args.session_id ? String(args.session_id) : undefined);
166
+ if (!message.trim()) {
167
+ return {
168
+ content: [{ type: "text", text: "message 不能为空" }],
169
+ isError: true,
170
+ };
171
+ }
172
+ if (!sessionId.trim()) {
173
+ return {
174
+ content: [{ type: "text", text: "session_id 不能为空" }],
175
+ isError: true,
176
+ };
177
+ }
178
+ const predefinedOptions = Array.isArray(args.predefined_options)
179
+ ? args.predefined_options.map(String)
180
+ : undefined;
181
+ const sessionLabel = args.session_label ? String(args.session_label) : undefined;
182
+ const isMarkdown = args.is_markdown !== false;
183
+ const id = randomUUID();
184
+ const { baseUrl } = await resolveBaseUrlAsync();
185
+ try {
186
+ const response = await waitForUserReply({
187
+ id,
188
+ session_id: sessionId,
189
+ session_label: sessionLabel,
190
+ message,
191
+ predefined_options: predefinedOptions,
192
+ is_markdown: isMarkdown,
193
+ }, { baseUrl });
194
+ return {
195
+ content: [
196
+ {
197
+ type: "text",
198
+ text: `[session: ${sessionId}]\n${response}`,
199
+ },
200
+ ],
201
+ };
202
+ }
203
+ catch (err) {
204
+ return {
205
+ content: [
206
+ {
207
+ type: "text",
208
+ text: `❌ 智存交互失败: ${String(err)}\n\nsession: ${sessionId}\n请确认对应 Cursor 窗口已加载扩展。`,
209
+ },
210
+ ],
211
+ isError: true,
212
+ };
213
+ }
214
+ }
215
+ return {
216
+ content: [{ type: "text", text: `未知工具: ${name}` }],
217
+ isError: true,
218
+ };
219
+ });
220
+ async function main() {
221
+ const transport = new StdioServerTransport();
222
+ await server.connect(transport);
223
+ }
224
+ main().catch((err) => {
225
+ console.error(err);
226
+ process.exit(1);
227
+ });
@@ -0,0 +1,18 @@
1
+ export interface WindowInstance {
2
+ windowId: string;
3
+ port: number;
4
+ pid: number;
5
+ workspaceFolders: string[];
6
+ updatedAt: number;
7
+ }
8
+ declare function normalizePath(p: string): string;
9
+ declare function readInstances(): WindowInstance[];
10
+ export declare function listAliveInstances(): Promise<WindowInstance[]>;
11
+ export declare function resolveBaseUrlSync(): string;
12
+ export declare function resolveBaseUrl(): string;
13
+ export declare function resolveBaseUrlAsync(): Promise<{
14
+ baseUrl: string;
15
+ matched?: WindowInstance;
16
+ allInstances: WindowInstance[];
17
+ }>;
18
+ export { readInstances, normalizePath };
@@ -0,0 +1,133 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ const CONFIG_DIR = path.join(os.homedir(), ".zhicun2");
5
+ const INSTANCES_FILE = path.join(CONFIG_DIR, "instances.json");
6
+ const PORT_FILE = path.join(CONFIG_DIR, "port.json");
7
+ const DEFAULT_PORT = 47382;
8
+ const PROBE_TIMEOUT_MS = 800;
9
+ function normalizePath(p) {
10
+ return path.resolve(p).replace(/\\/g, "/").toLowerCase();
11
+ }
12
+ function readInstances() {
13
+ try {
14
+ if (!fs.existsSync(INSTANCES_FILE)) {
15
+ return [];
16
+ }
17
+ const data = JSON.parse(fs.readFileSync(INSTANCES_FILE, "utf-8"));
18
+ return Array.isArray(data.instances) ? data.instances : [];
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ }
24
+ function readLegacyPort() {
25
+ try {
26
+ if (!fs.existsSync(PORT_FILE)) {
27
+ return null;
28
+ }
29
+ const data = JSON.parse(fs.readFileSync(PORT_FILE, "utf-8"));
30
+ return typeof data.port === "number" ? data.port : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ async function probeHealth(port) {
37
+ try {
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
40
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
41
+ signal: controller.signal,
42
+ });
43
+ clearTimeout(timer);
44
+ if (!res.ok) {
45
+ return false;
46
+ }
47
+ const data = (await res.json());
48
+ return data.ok === true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ export async function listAliveInstances() {
55
+ const instances = readInstances();
56
+ const alive = [];
57
+ for (const inst of instances) {
58
+ if (await probeHealth(inst.port)) {
59
+ alive.push(inst);
60
+ }
61
+ }
62
+ return alive;
63
+ }
64
+ function pickBestMatch(candidates) {
65
+ if (candidates.length === 0) {
66
+ return undefined;
67
+ }
68
+ return [...candidates].sort((a, b) => b.updatedAt - a.updatedAt)[0];
69
+ }
70
+ export function resolveBaseUrlSync() {
71
+ if (process.env.ZHICUN2_PORT) {
72
+ return `http://127.0.0.1:${process.env.ZHICUN2_PORT}`;
73
+ }
74
+ const windowId = process.env.ZHICUN2_WINDOW_ID;
75
+ if (windowId) {
76
+ const matched = readInstances().find((i) => i.windowId === windowId);
77
+ if (matched) {
78
+ return `http://127.0.0.1:${matched.port}`;
79
+ }
80
+ }
81
+ const workspace = process.env.ZHICUN2_WORKSPACE;
82
+ if (workspace) {
83
+ const target = normalizePath(workspace);
84
+ const matches = readInstances().filter((i) => i.workspaceFolders.some((f) => normalizePath(f) === target));
85
+ const best = pickBestMatch(matches);
86
+ if (best) {
87
+ return `http://127.0.0.1:${best.port}`;
88
+ }
89
+ }
90
+ const legacy = readLegacyPort();
91
+ if (legacy) {
92
+ return `http://127.0.0.1:${legacy}`;
93
+ }
94
+ return `http://127.0.0.1:${DEFAULT_PORT}`;
95
+ }
96
+ export function resolveBaseUrl() {
97
+ return resolveBaseUrlSync();
98
+ }
99
+ export async function resolveBaseUrlAsync() {
100
+ const allInstances = await listAliveInstances();
101
+ if (process.env.ZHICUN2_PORT) {
102
+ return {
103
+ baseUrl: `http://127.0.0.1:${process.env.ZHICUN2_PORT}`,
104
+ allInstances,
105
+ };
106
+ }
107
+ const windowId = process.env.ZHICUN2_WINDOW_ID;
108
+ if (windowId) {
109
+ const matched = allInstances.find((i) => i.windowId === windowId);
110
+ if (matched) {
111
+ return { baseUrl: `http://127.0.0.1:${matched.port}`, matched, allInstances };
112
+ }
113
+ }
114
+ const workspace = process.env.ZHICUN2_WORKSPACE;
115
+ if (workspace) {
116
+ const target = normalizePath(workspace);
117
+ const matches = allInstances.filter((i) => i.workspaceFolders.some((f) => normalizePath(f) === target));
118
+ const best = pickBestMatch(matches);
119
+ if (best) {
120
+ return { baseUrl: `http://127.0.0.1:${best.port}`, matched: best, allInstances };
121
+ }
122
+ }
123
+ if (allInstances.length === 1) {
124
+ const only = allInstances[0];
125
+ return { baseUrl: `http://127.0.0.1:${only.port}`, matched: only, allInstances };
126
+ }
127
+ const legacy = readLegacyPort();
128
+ if (legacy) {
129
+ return { baseUrl: `http://127.0.0.1:${legacy}`, allInstances };
130
+ }
131
+ return { baseUrl: `http://127.0.0.1:${DEFAULT_PORT}`, allInstances };
132
+ }
133
+ export { readInstances, normalizePath };
@@ -0,0 +1,5 @@
1
+ import { normalizePath } from "./registry.js";
2
+ export declare function deriveDefaultSessionId(workspacePath?: string): string;
3
+ export declare function normalizeSessionId(sessionId: string | undefined): string;
4
+ export declare function resolveDefaultSessionId(): string;
5
+ export { normalizePath };
@@ -0,0 +1,32 @@
1
+ import * as path from "path";
2
+ import { normalizePath } from "./registry.js";
3
+ export function deriveDefaultSessionId(workspacePath) {
4
+ if (workspacePath && workspacePath.trim()) {
5
+ const base = path.basename(path.resolve(workspacePath.trim()));
6
+ if (base) {
7
+ return `${base}/main`;
8
+ }
9
+ }
10
+ return "global/main";
11
+ }
12
+ export function normalizeSessionId(sessionId) {
13
+ const trimmed = String(sessionId ?? "").trim();
14
+ if (!trimmed || trimmed === "default") {
15
+ const workspace = process.env.ZHICUN2_WORKSPACE;
16
+ if (workspace) {
17
+ return deriveDefaultSessionId(workspace);
18
+ }
19
+ return "global/main";
20
+ }
21
+ return trimmed;
22
+ }
23
+ export function resolveDefaultSessionId() {
24
+ if (process.env.ZHICUN2_SESSION) {
25
+ return process.env.ZHICUN2_SESSION;
26
+ }
27
+ if (process.env.ZHICUN2_WORKSPACE) {
28
+ return deriveDefaultSessionId(process.env.ZHICUN2_WORKSPACE);
29
+ }
30
+ return "global/main";
31
+ }
32
+ export { normalizePath };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "zhicun2-mcp",
3
+ "version": "0.3.2",
4
+ "description": "智存 2.0 MCP — 阻塞等待用户在 Cursor 扩展侧边栏中交互",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "zhicun2-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "cursor",
20
+ "zhicun2",
21
+ "model-context-protocol"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/zhicun/zhicun2.0.git",
27
+ "directory": "mcp"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "watch": "tsc -w",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.12.1"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^20.10.0",
39
+ "typescript": "^5.3.0"
40
+ }
41
+ }