dsh-cuigengji-plugin 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +1 -1
- package/cordis.patch.yml +3 -3
- package/docs/CHANGELOG.md +15 -0
- package/docs/agent-installation.md +19 -12
- package/docs/architecture.md +74 -74
- package/docs/desktop-migration-20260930.md +26 -26
- package/docs/development.md +7 -3
- package/docs/getting-started.md +17 -7
- package/docs/layout-progress.md +31 -31
- package/docs/market-listing.md +5 -3
- package/docs/npm-publishing.md +35 -11
- package/docs/package-name-migration.md +3 -3
- package/docs/planning-board.md +44 -44
- package/docs/presets.md +24 -24
- package/docs/release-process.md +45 -10
- package/docs/tavern-compatibility-audit-20261001.md +109 -109
- package/docs/test-report.md +139 -129
- package/docs/upgrade.md +3 -1
- package/docs/usage-review.md +13 -13
- package/lib/client.js +19 -14
- package/package.json +19 -14
- package/skills/cuigengji-maintenance/SKILL.md +2 -2
- package/src/adapters/dsh/prompt.js +36 -36
- package/src/adapters/dsh/rpc.js +96 -96
- package/src/adapters/dsh/skills.js +17 -17
- package/src/adapters/mcp/actions.js +6 -6
- package/src/adapters/mcp/client.js +172 -172
- package/src/adapters/mcp/server.js +40 -40
- package/src/application/bindings/session.js +72 -72
- package/src/application/queries/context.js +15 -15
- package/src/application/store.js +412 -412
- package/src/client/features/chapters/clipboard.ts +27 -27
- package/src/client/features/chapters/history.ts +11 -11
- package/src/client/features/chapters/index.jsx +118 -118
- package/src/client/features/chapters/sync.ts +155 -155
- package/src/client/features/chapters/useChapterSync.ts +39 -39
- package/src/client/features/context/index.jsx +22 -22
- package/src/client/features/memory/index.jsx +73 -73
- package/src/client/features/planning/Decorations.tsx +1 -1
- package/src/client/features/planning/Pages.tsx +11 -11
- package/src/client/features/planning/data.js +16 -16
- package/src/client/index.tsx +130 -130
- package/src/client/launcher.tsx +78 -78
- package/src/client/shared/creation.tsx +34 -34
- package/src/client/shared/detail-page.tsx +61 -61
- package/src/client/shared/drafts.js +17 -17
- package/src/client/shared/evidence.jsx +11 -11
- package/src/client/shared/groups.tsx +34 -34
- package/src/client/shared/preferences.tsx +104 -104
- package/src/client/shared/select-style.ts +25 -25
- package/src/client/shared/state.js +95 -95
- package/src/client/shared/ui.jsx +19 -19
- package/src/client/shared/workspace.tsx +11 -11
- package/src/client/style.ts +274 -274
- package/src/client/visual.ts +190 -190
- package/src/contracts/actions.js +19 -19
- package/src/contracts/planning.ts +52 -52
- package/src/domain/manuscript.ts +4 -4
- package/src/domain/planning/index.ts +113 -113
- package/src/domain/planning/model.ts +59 -59
- package/src/domain/preset/index.js +89 -89
- package/src/index.js +91 -91
- package/src/infrastructure/migrations/legacy.js +96 -96
- package/src/infrastructure/migrations/tavern.js +78 -78
- package/src/infrastructure/persistence/atomic-store.ts +80 -80
- package/src/infrastructure/persistence/project-layout.ts +1 -1
|
@@ -1,172 +1,172 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
-
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
-
import { assertMemoryAction, toolName } from './actions.js';
|
|
7
|
-
|
|
8
|
-
const MAX_BODY_BYTES = 1024 * 1024;
|
|
9
|
-
|
|
10
|
-
export function memoryRuntime(options = {}, runtime = process) {
|
|
11
|
-
const command = options.nodeCommand ?? runtime.env.CUIGENGJI_DSH_NODE ?? runtime.execPath;
|
|
12
|
-
const env = {};
|
|
13
|
-
if (runtime.env.NODE_OPTIONS) env.NODE_OPTIONS = runtime.env.NODE_OPTIONS;
|
|
14
|
-
// Desktop hosts use the Electron executable as Node. The SDK's default
|
|
15
|
-
// environment omits this switch, which otherwise launches a second GUI.
|
|
16
|
-
if (runtime.versions.electron || runtime.env.ELECTRON_RUN_AS_NODE === '1') {
|
|
17
|
-
env.ELECTRON_RUN_AS_NODE = '1';
|
|
18
|
-
}
|
|
19
|
-
return { command, env };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class MemoryBridge {
|
|
23
|
-
constructor(store, options = {}) {
|
|
24
|
-
this.store = store;
|
|
25
|
-
this.dispatch = options.dispatch ?? ((action, args, actor) => store.dispatch(action, args, actor));
|
|
26
|
-
this.resolveNovel = options.resolveNovel;
|
|
27
|
-
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
28
|
-
const runtime = memoryRuntime(options);
|
|
29
|
-
this.nodeCommand = runtime.command;
|
|
30
|
-
this.runtimeEnv = runtime.env;
|
|
31
|
-
this.sessions = new Map();
|
|
32
|
-
this.tokens = new Map();
|
|
33
|
-
this.closed = false;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async start() {
|
|
37
|
-
if (this.closed) throw new Error('记忆服务已关闭');
|
|
38
|
-
if (this.starting) return this.starting;
|
|
39
|
-
this.starting = (async () => {
|
|
40
|
-
this.server = http.createServer((request, response) => { void this.handle(request, response); });
|
|
41
|
-
this.server.requestTimeout = this.timeoutMs;
|
|
42
|
-
await new Promise((resolve, reject) => {
|
|
43
|
-
this.server.once('error', reject);
|
|
44
|
-
this.server.listen(0, '127.0.0.1', resolve);
|
|
45
|
-
});
|
|
46
|
-
this.endpoint = `http://127.0.0.1:${this.server.address().port}/memory`;
|
|
47
|
-
return this;
|
|
48
|
-
})();
|
|
49
|
-
return this.starting;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async handle(request, response) {
|
|
53
|
-
const reply = (status, data) => {
|
|
54
|
-
if (response.destroyed) return;
|
|
55
|
-
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
56
|
-
response.end(JSON.stringify(data));
|
|
57
|
-
};
|
|
58
|
-
try {
|
|
59
|
-
if (request.method !== 'POST' || request.url !== '/memory') return reply(404, { error: '未知接口' });
|
|
60
|
-
const sessionId = this.tokens.get((request.headers.authorization ?? '').replace(/^Bearer /, ''));
|
|
61
|
-
if (!sessionId) return reply(401, { error: '会话凭据失效' });
|
|
62
|
-
let size = 0;
|
|
63
|
-
const chunks = [];
|
|
64
|
-
for await (const chunk of request) {
|
|
65
|
-
size += chunk.length;
|
|
66
|
-
if (size > MAX_BODY_BYTES) return reply(413, { error: '记忆请求过大' });
|
|
67
|
-
chunks.push(chunk);
|
|
68
|
-
}
|
|
69
|
-
const { action, args = {} } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
70
|
-
assertMemoryAction(action);
|
|
71
|
-
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('参数必须是对象');
|
|
72
|
-
const actor = { kind: 'agent', sessionId };
|
|
73
|
-
const novelId = await this.boundNovel(sessionId);
|
|
74
|
-
if (!novelId) throw new Error('当前会话尚未绑定小说');
|
|
75
|
-
if (args.novelId && args.novelId !== novelId) throw new Error('不能访问其他小说');
|
|
76
|
-
// Never forward model-supplied session/actor identities.
|
|
77
|
-
const { sessionId: _session, actor: _actor, ...safeArgs } = args;
|
|
78
|
-
const result = await this.dispatch(action, { ...safeArgs, novelId }, actor);
|
|
79
|
-
reply(200, { result });
|
|
80
|
-
} catch (error) {
|
|
81
|
-
reply(400, { error: error.message });
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async boundNovel(sessionId) {
|
|
86
|
-
if (this.resolveNovel) return this.resolveNovel(sessionId);
|
|
87
|
-
const binding = await this.store.dispatch('binding.get', {}, { kind: 'agent', sessionId });
|
|
88
|
-
return binding?.novelId;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async connection(sessionId) {
|
|
92
|
-
if (!sessionId || typeof sessionId !== 'string') throw new Error('缺少会话 ID');
|
|
93
|
-
await this.start();
|
|
94
|
-
const existing = this.sessions.get(sessionId);
|
|
95
|
-
if (existing) return existing.pending ?? existing;
|
|
96
|
-
const entry = { pending: null };
|
|
97
|
-
entry.pending = this.connect(sessionId, entry);
|
|
98
|
-
this.sessions.set(sessionId, entry);
|
|
99
|
-
try { return await entry.pending; }
|
|
100
|
-
catch (error) {
|
|
101
|
-
if (this.sessions.get(sessionId) === entry) this.sessions.delete(sessionId);
|
|
102
|
-
throw error;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async connect(sessionId, entry) {
|
|
107
|
-
const token = randomBytes(32).toString('hex');
|
|
108
|
-
this.tokens.set(token, sessionId);
|
|
109
|
-
const env = { ...this.runtimeEnv, CUIGENGJI_MEMORY_ENDPOINT: this.endpoint, CUIGENGJI_MEMORY_TOKEN: token };
|
|
110
|
-
const transport = new StdioClientTransport({
|
|
111
|
-
command: this.nodeCommand,
|
|
112
|
-
args: [fileURLToPath(new URL('./server.js', import.meta.url))],
|
|
113
|
-
env,
|
|
114
|
-
stderr: 'pipe',
|
|
115
|
-
});
|
|
116
|
-
const client = new Client({ name: 'cuigengji-host', version: '0.1.0' });
|
|
117
|
-
// Keep child diagnostics out of model output; drain to avoid pipe backpressure.
|
|
118
|
-
transport.stderr?.on('data', () => {});
|
|
119
|
-
client.onclose = () => {
|
|
120
|
-
this.tokens.delete(token);
|
|
121
|
-
// A reconnect may have replaced this entry. Never tear down the new
|
|
122
|
-
// connection when an older MCP child exits asynchronously.
|
|
123
|
-
if (this.sessions.get(sessionId) === entry) this.sessions.delete(sessionId);
|
|
124
|
-
};
|
|
125
|
-
try {
|
|
126
|
-
await client.connect(transport);
|
|
127
|
-
return { client, transport, token };
|
|
128
|
-
} catch (error) {
|
|
129
|
-
this.tokens.delete(token);
|
|
130
|
-
await transport.close().catch(() => {});
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async call(sessionId, action, args = {}, { signal } = {}) {
|
|
136
|
-
assertMemoryAction(action);
|
|
137
|
-
if (signal?.aborted) throw signal.reason ?? new Error('已取消');
|
|
138
|
-
const { client } = await this.connection(sessionId);
|
|
139
|
-
const result = await client.callTool({ name: toolName(action), arguments: args }, undefined, {
|
|
140
|
-
timeout: this.timeoutMs, signal,
|
|
141
|
-
});
|
|
142
|
-
const output = result.content?.find(item => item.type === 'text')?.text;
|
|
143
|
-
if (result.isError) throw new Error(output ?? '记忆工具失败');
|
|
144
|
-
return JSON.parse(output ?? 'null');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async disconnect(sessionId) {
|
|
148
|
-
const entry = this.sessions.get(sessionId);
|
|
149
|
-
this.sessions.delete(sessionId);
|
|
150
|
-
if (!entry) return;
|
|
151
|
-
const connection = await (entry.pending ?? entry).catch(() => null);
|
|
152
|
-
if (!connection) return;
|
|
153
|
-
this.tokens.delete(connection.token);
|
|
154
|
-
await connection.client.close();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async close() {
|
|
158
|
-
this.closed = true;
|
|
159
|
-
await Promise.allSettled([...this.sessions.keys()].map(sessionId => this.disconnect(sessionId)));
|
|
160
|
-
this.tokens.clear();
|
|
161
|
-
if (this.server) {
|
|
162
|
-
this.server.closeAllConnections();
|
|
163
|
-
await new Promise(resolve => this.server.close(resolve));
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export class MemoryClient {
|
|
169
|
-
constructor(bridge, sessionId) { this.bridge = bridge; this.sessionId = sessionId; }
|
|
170
|
-
call(action, args, options) { return this.bridge.call(this.sessionId, action, args, options); }
|
|
171
|
-
close() { return this.bridge.disconnect(this.sessionId); }
|
|
172
|
-
}
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
+
import { assertMemoryAction, toolName } from './actions.js';
|
|
7
|
+
|
|
8
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export function memoryRuntime(options = {}, runtime = process) {
|
|
11
|
+
const command = options.nodeCommand ?? runtime.env.CUIGENGJI_DSH_NODE ?? runtime.execPath;
|
|
12
|
+
const env = {};
|
|
13
|
+
if (runtime.env.NODE_OPTIONS) env.NODE_OPTIONS = runtime.env.NODE_OPTIONS;
|
|
14
|
+
// Desktop hosts use the Electron executable as Node. The SDK's default
|
|
15
|
+
// environment omits this switch, which otherwise launches a second GUI.
|
|
16
|
+
if (runtime.versions.electron || runtime.env.ELECTRON_RUN_AS_NODE === '1') {
|
|
17
|
+
env.ELECTRON_RUN_AS_NODE = '1';
|
|
18
|
+
}
|
|
19
|
+
return { command, env };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MemoryBridge {
|
|
23
|
+
constructor(store, options = {}) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.dispatch = options.dispatch ?? ((action, args, actor) => store.dispatch(action, args, actor));
|
|
26
|
+
this.resolveNovel = options.resolveNovel;
|
|
27
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
28
|
+
const runtime = memoryRuntime(options);
|
|
29
|
+
this.nodeCommand = runtime.command;
|
|
30
|
+
this.runtimeEnv = runtime.env;
|
|
31
|
+
this.sessions = new Map();
|
|
32
|
+
this.tokens = new Map();
|
|
33
|
+
this.closed = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async start() {
|
|
37
|
+
if (this.closed) throw new Error('记忆服务已关闭');
|
|
38
|
+
if (this.starting) return this.starting;
|
|
39
|
+
this.starting = (async () => {
|
|
40
|
+
this.server = http.createServer((request, response) => { void this.handle(request, response); });
|
|
41
|
+
this.server.requestTimeout = this.timeoutMs;
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
this.server.once('error', reject);
|
|
44
|
+
this.server.listen(0, '127.0.0.1', resolve);
|
|
45
|
+
});
|
|
46
|
+
this.endpoint = `http://127.0.0.1:${this.server.address().port}/memory`;
|
|
47
|
+
return this;
|
|
48
|
+
})();
|
|
49
|
+
return this.starting;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async handle(request, response) {
|
|
53
|
+
const reply = (status, data) => {
|
|
54
|
+
if (response.destroyed) return;
|
|
55
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
56
|
+
response.end(JSON.stringify(data));
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
if (request.method !== 'POST' || request.url !== '/memory') return reply(404, { error: '未知接口' });
|
|
60
|
+
const sessionId = this.tokens.get((request.headers.authorization ?? '').replace(/^Bearer /, ''));
|
|
61
|
+
if (!sessionId) return reply(401, { error: '会话凭据失效' });
|
|
62
|
+
let size = 0;
|
|
63
|
+
const chunks = [];
|
|
64
|
+
for await (const chunk of request) {
|
|
65
|
+
size += chunk.length;
|
|
66
|
+
if (size > MAX_BODY_BYTES) return reply(413, { error: '记忆请求过大' });
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
}
|
|
69
|
+
const { action, args = {} } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
70
|
+
assertMemoryAction(action);
|
|
71
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('参数必须是对象');
|
|
72
|
+
const actor = { kind: 'agent', sessionId };
|
|
73
|
+
const novelId = await this.boundNovel(sessionId);
|
|
74
|
+
if (!novelId) throw new Error('当前会话尚未绑定小说');
|
|
75
|
+
if (args.novelId && args.novelId !== novelId) throw new Error('不能访问其他小说');
|
|
76
|
+
// Never forward model-supplied session/actor identities.
|
|
77
|
+
const { sessionId: _session, actor: _actor, ...safeArgs } = args;
|
|
78
|
+
const result = await this.dispatch(action, { ...safeArgs, novelId }, actor);
|
|
79
|
+
reply(200, { result });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
reply(400, { error: error.message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async boundNovel(sessionId) {
|
|
86
|
+
if (this.resolveNovel) return this.resolveNovel(sessionId);
|
|
87
|
+
const binding = await this.store.dispatch('binding.get', {}, { kind: 'agent', sessionId });
|
|
88
|
+
return binding?.novelId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async connection(sessionId) {
|
|
92
|
+
if (!sessionId || typeof sessionId !== 'string') throw new Error('缺少会话 ID');
|
|
93
|
+
await this.start();
|
|
94
|
+
const existing = this.sessions.get(sessionId);
|
|
95
|
+
if (existing) return existing.pending ?? existing;
|
|
96
|
+
const entry = { pending: null };
|
|
97
|
+
entry.pending = this.connect(sessionId, entry);
|
|
98
|
+
this.sessions.set(sessionId, entry);
|
|
99
|
+
try { return await entry.pending; }
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (this.sessions.get(sessionId) === entry) this.sessions.delete(sessionId);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async connect(sessionId, entry) {
|
|
107
|
+
const token = randomBytes(32).toString('hex');
|
|
108
|
+
this.tokens.set(token, sessionId);
|
|
109
|
+
const env = { ...this.runtimeEnv, CUIGENGJI_MEMORY_ENDPOINT: this.endpoint, CUIGENGJI_MEMORY_TOKEN: token };
|
|
110
|
+
const transport = new StdioClientTransport({
|
|
111
|
+
command: this.nodeCommand,
|
|
112
|
+
args: [fileURLToPath(new URL('./server.js', import.meta.url))],
|
|
113
|
+
env,
|
|
114
|
+
stderr: 'pipe',
|
|
115
|
+
});
|
|
116
|
+
const client = new Client({ name: 'cuigengji-host', version: '0.1.0' });
|
|
117
|
+
// Keep child diagnostics out of model output; drain to avoid pipe backpressure.
|
|
118
|
+
transport.stderr?.on('data', () => {});
|
|
119
|
+
client.onclose = () => {
|
|
120
|
+
this.tokens.delete(token);
|
|
121
|
+
// A reconnect may have replaced this entry. Never tear down the new
|
|
122
|
+
// connection when an older MCP child exits asynchronously.
|
|
123
|
+
if (this.sessions.get(sessionId) === entry) this.sessions.delete(sessionId);
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
await client.connect(transport);
|
|
127
|
+
return { client, transport, token };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.tokens.delete(token);
|
|
130
|
+
await transport.close().catch(() => {});
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async call(sessionId, action, args = {}, { signal } = {}) {
|
|
136
|
+
assertMemoryAction(action);
|
|
137
|
+
if (signal?.aborted) throw signal.reason ?? new Error('已取消');
|
|
138
|
+
const { client } = await this.connection(sessionId);
|
|
139
|
+
const result = await client.callTool({ name: toolName(action), arguments: args }, undefined, {
|
|
140
|
+
timeout: this.timeoutMs, signal,
|
|
141
|
+
});
|
|
142
|
+
const output = result.content?.find(item => item.type === 'text')?.text;
|
|
143
|
+
if (result.isError) throw new Error(output ?? '记忆工具失败');
|
|
144
|
+
return JSON.parse(output ?? 'null');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async disconnect(sessionId) {
|
|
148
|
+
const entry = this.sessions.get(sessionId);
|
|
149
|
+
this.sessions.delete(sessionId);
|
|
150
|
+
if (!entry) return;
|
|
151
|
+
const connection = await (entry.pending ?? entry).catch(() => null);
|
|
152
|
+
if (!connection) return;
|
|
153
|
+
this.tokens.delete(connection.token);
|
|
154
|
+
await connection.client.close();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async close() {
|
|
158
|
+
this.closed = true;
|
|
159
|
+
await Promise.allSettled([...this.sessions.keys()].map(sessionId => this.disconnect(sessionId)));
|
|
160
|
+
this.tokens.clear();
|
|
161
|
+
if (this.server) {
|
|
162
|
+
this.server.closeAllConnections();
|
|
163
|
+
await new Promise(resolve => this.server.close(resolve));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class MemoryClient {
|
|
169
|
+
constructor(bridge, sessionId) { this.bridge = bridge; this.sessionId = sessionId; }
|
|
170
|
+
call(action, args, options) { return this.bridge.call(this.sessionId, action, args, options); }
|
|
171
|
+
close() { return this.bridge.disconnect(this.sessionId); }
|
|
172
|
+
}
|
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
-
import { MEMORY_ACTIONS, toolName } from './actions.js';
|
|
5
|
-
|
|
6
|
-
// This process owns no project files. Its capability is one session-bound token.
|
|
7
|
-
const endpoint = process.env.CUIGENGJI_MEMORY_ENDPOINT;
|
|
8
|
-
const token = process.env.CUIGENGJI_MEMORY_TOKEN;
|
|
9
|
-
if (!endpoint || !token) throw new Error('缺少 cuigengji 宿主连接信息');
|
|
10
|
-
const address = new URL(endpoint);
|
|
11
|
-
if (address.protocol !== 'http:' || address.hostname !== '127.0.0.1') {
|
|
12
|
-
throw new Error('记忆服务必须为本机 loopback 地址');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const server = new Server({ name: 'cuigengji-memory', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
16
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
17
|
-
tools: MEMORY_ACTIONS.map(action => ({
|
|
18
|
-
name: toolName(action),
|
|
19
|
-
description: `当前小说记忆操作 ${action}。小说由会话绑定;修改须携带读取到的版本。`,
|
|
20
|
-
inputSchema: { type: 'object', additionalProperties: true },
|
|
21
|
-
})),
|
|
22
|
-
}));
|
|
23
|
-
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
24
|
-
const action = MEMORY_ACTIONS.find(item => toolName(item) === request.params.name);
|
|
25
|
-
if (!action) return { isError: true, content: [{ type: 'text', text: '未知记忆工具' }] };
|
|
26
|
-
try {
|
|
27
|
-
const response = await fetch(endpoint, {
|
|
28
|
-
method: 'POST',
|
|
29
|
-
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
30
|
-
body: JSON.stringify({ action, args: request.params.arguments ?? {} }),
|
|
31
|
-
signal: AbortSignal.any([extra.signal, AbortSignal.timeout(30_000)]),
|
|
32
|
-
});
|
|
33
|
-
const body = await response.json();
|
|
34
|
-
if (!response.ok) return { isError: true, content: [{ type: 'text', text: body.error ?? '记忆操作失败' }] };
|
|
35
|
-
return { content: [{ type: 'text', text: JSON.stringify(body.result ?? null) }] };
|
|
36
|
-
} catch (error) {
|
|
37
|
-
return { isError: true, content: [{ type: 'text', text: error.message }] };
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
await server.connect(new StdioServerTransport());
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { MEMORY_ACTIONS, toolName } from './actions.js';
|
|
5
|
+
|
|
6
|
+
// This process owns no project files. Its capability is one session-bound token.
|
|
7
|
+
const endpoint = process.env.CUIGENGJI_MEMORY_ENDPOINT;
|
|
8
|
+
const token = process.env.CUIGENGJI_MEMORY_TOKEN;
|
|
9
|
+
if (!endpoint || !token) throw new Error('缺少 cuigengji 宿主连接信息');
|
|
10
|
+
const address = new URL(endpoint);
|
|
11
|
+
if (address.protocol !== 'http:' || address.hostname !== '127.0.0.1') {
|
|
12
|
+
throw new Error('记忆服务必须为本机 loopback 地址');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const server = new Server({ name: 'cuigengji-memory', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
16
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
17
|
+
tools: MEMORY_ACTIONS.map(action => ({
|
|
18
|
+
name: toolName(action),
|
|
19
|
+
description: `当前小说记忆操作 ${action}。小说由会话绑定;修改须携带读取到的版本。`,
|
|
20
|
+
inputSchema: { type: 'object', additionalProperties: true },
|
|
21
|
+
})),
|
|
22
|
+
}));
|
|
23
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
24
|
+
const action = MEMORY_ACTIONS.find(item => toolName(item) === request.params.name);
|
|
25
|
+
if (!action) return { isError: true, content: [{ type: 'text', text: '未知记忆工具' }] };
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(endpoint, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ action, args: request.params.arguments ?? {} }),
|
|
31
|
+
signal: AbortSignal.any([extra.signal, AbortSignal.timeout(30_000)]),
|
|
32
|
+
});
|
|
33
|
+
const body = await response.json();
|
|
34
|
+
if (!response.ok) return { isError: true, content: [{ type: 'text', text: body.error ?? '记忆操作失败' }] };
|
|
35
|
+
return { content: [{ type: 'text', text: JSON.stringify(body.result ?? null) }] };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { isError: true, content: [{ type: 'text', text: error.message }] };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
import { isReadAction } from '../../contracts/actions.js';
|
|
2
|
-
import {novelLifecycleActions, recyclesNovel} from '../novels/lifecycle.ts';
|
|
3
|
-
import {createHash} from 'node:crypto';
|
|
4
|
-
const sessionKey = id => createHash('sha256').update(id || '').digest('hex');
|
|
5
|
-
|
|
6
|
-
export function createSessionPolicy({ store, audit, ready = async () => undefined }) {
|
|
7
|
-
const active = new Set();
|
|
8
|
-
const resolveBinding = async sessionId => {
|
|
9
|
-
await ready();
|
|
10
|
-
return store.dispatch('binding.get', {}, { kind: 'agent', sessionId });
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
async function dispatch(action, args = {}, actor = { kind: 'agent' }) {
|
|
14
|
-
await ready();
|
|
15
|
-
if (action === 'binding.set' && active.has(actor.sessionId)) throw new Error('会话正在运行,请停止后切换小说或章节');
|
|
16
|
-
if (novelLifecycleActions.includes(action) && actor.kind !== 'human') throw new Error('整部作品的回收、恢复与永久删除需要作者操作');
|
|
17
|
-
if (actor.kind === 'agent') {
|
|
18
|
-
const binding = await resolveBinding(actor.sessionId);
|
|
19
|
-
// Inspecting an unbound session is valid and lets the agent explain how
|
|
20
|
-
// to begin; all novel data operations still require an author binding.
|
|
21
|
-
if (action === 'binding.get') return binding;
|
|
22
|
-
if (!binding) throw new Error('请先在催更姬面板绑定小说');
|
|
23
|
-
if (args.novelId && args.novelId !== binding.novelId) throw new Error('不能访问另一部小说');
|
|
24
|
-
args = { ...args, novelId: binding.novelId };
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
const guard = action === 'binding.set' || recyclesNovel(action, args) ? state => {
|
|
28
|
-
if (action === 'binding.set' && active.has(actor.sessionId)) throw new Error('会话正在运行,请停止后切换小说或章节');
|
|
29
|
-
if (recyclesNovel(action, args)) {
|
|
30
|
-
const novelId = args.novelId || state.bindings[sessionKey(actor.sessionId)]?.novelId;
|
|
31
|
-
for (const sessionId of active) if (novelId && state.bindings[sessionKey(sessionId)]?.novelId === novelId) {
|
|
32
|
-
throw Object.assign(new Error('这部作品仍有 AI 写作任务在运行,请先停止相关会话,再移入回收站'), {code: 'NOVEL_BUSY'});
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
} : undefined;
|
|
36
|
-
const result = await store.dispatch(action, args, actor, guard);
|
|
37
|
-
if (!isReadAction(action)) await audit(actor.sessionId, {
|
|
38
|
-
action, actor: actor.kind, entityId: result?.id, revision: result?.revision, reason: args.reason || '',
|
|
39
|
-
});
|
|
40
|
-
return result;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function register(ctx, memory) {
|
|
44
|
-
const disconnect = sessionId => {
|
|
45
|
-
// Status/disposal are emit events; do not leave an unhandled rejection.
|
|
46
|
-
void memory.disconnect(sessionId).catch(error => ctx.logger?.warn?.(`cuigengji memory cleanup: ${error.message}`));
|
|
47
|
-
};
|
|
48
|
-
ctx.on('agent/pre-step', async ({ agent }, next) => {
|
|
49
|
-
const sessionId = String(agent.id);
|
|
50
|
-
// DSH only emits pre-step for running agents. Record this before any I/O
|
|
51
|
-
// so a simultaneous recycle sees the active session inside the lock.
|
|
52
|
-
active.add(sessionId);
|
|
53
|
-
try {await resolveBinding(sessionId);}
|
|
54
|
-
catch (error) {if (error.code !== 'UPGRADE_REQUIRED') throw error;}
|
|
55
|
-
return next();
|
|
56
|
-
});
|
|
57
|
-
ctx.on('agent/status', ({ agent, status }) => {
|
|
58
|
-
const sessionId = String(agent.id);
|
|
59
|
-
if (status === 'running') active.add(sessionId);
|
|
60
|
-
else {
|
|
61
|
-
active.delete(sessionId);
|
|
62
|
-
disconnect(sessionId);
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
ctx.on('agent/disposed', ({ agent }) => {
|
|
66
|
-
const sessionId = String(agent.id);
|
|
67
|
-
active.delete(sessionId);
|
|
68
|
-
disconnect(sessionId);
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
return { dispatch, resolveBinding, register };
|
|
72
|
-
}
|
|
1
|
+
import { isReadAction } from '../../contracts/actions.js';
|
|
2
|
+
import {novelLifecycleActions, recyclesNovel} from '../novels/lifecycle.ts';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
const sessionKey = id => createHash('sha256').update(id || '').digest('hex');
|
|
5
|
+
|
|
6
|
+
export function createSessionPolicy({ store, audit, ready = async () => undefined }) {
|
|
7
|
+
const active = new Set();
|
|
8
|
+
const resolveBinding = async sessionId => {
|
|
9
|
+
await ready();
|
|
10
|
+
return store.dispatch('binding.get', {}, { kind: 'agent', sessionId });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function dispatch(action, args = {}, actor = { kind: 'agent' }) {
|
|
14
|
+
await ready();
|
|
15
|
+
if (action === 'binding.set' && active.has(actor.sessionId)) throw new Error('会话正在运行,请停止后切换小说或章节');
|
|
16
|
+
if (novelLifecycleActions.includes(action) && actor.kind !== 'human') throw new Error('整部作品的回收、恢复与永久删除需要作者操作');
|
|
17
|
+
if (actor.kind === 'agent') {
|
|
18
|
+
const binding = await resolveBinding(actor.sessionId);
|
|
19
|
+
// Inspecting an unbound session is valid and lets the agent explain how
|
|
20
|
+
// to begin; all novel data operations still require an author binding.
|
|
21
|
+
if (action === 'binding.get') return binding;
|
|
22
|
+
if (!binding) throw new Error('请先在催更姬面板绑定小说');
|
|
23
|
+
if (args.novelId && args.novelId !== binding.novelId) throw new Error('不能访问另一部小说');
|
|
24
|
+
args = { ...args, novelId: binding.novelId };
|
|
25
|
+
|
|
26
|
+
}
|
|
27
|
+
const guard = action === 'binding.set' || recyclesNovel(action, args) ? state => {
|
|
28
|
+
if (action === 'binding.set' && active.has(actor.sessionId)) throw new Error('会话正在运行,请停止后切换小说或章节');
|
|
29
|
+
if (recyclesNovel(action, args)) {
|
|
30
|
+
const novelId = args.novelId || state.bindings[sessionKey(actor.sessionId)]?.novelId;
|
|
31
|
+
for (const sessionId of active) if (novelId && state.bindings[sessionKey(sessionId)]?.novelId === novelId) {
|
|
32
|
+
throw Object.assign(new Error('这部作品仍有 AI 写作任务在运行,请先停止相关会话,再移入回收站'), {code: 'NOVEL_BUSY'});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} : undefined;
|
|
36
|
+
const result = await store.dispatch(action, args, actor, guard);
|
|
37
|
+
if (!isReadAction(action)) await audit(actor.sessionId, {
|
|
38
|
+
action, actor: actor.kind, entityId: result?.id, revision: result?.revision, reason: args.reason || '',
|
|
39
|
+
});
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function register(ctx, memory) {
|
|
44
|
+
const disconnect = sessionId => {
|
|
45
|
+
// Status/disposal are emit events; do not leave an unhandled rejection.
|
|
46
|
+
void memory.disconnect(sessionId).catch(error => ctx.logger?.warn?.(`cuigengji memory cleanup: ${error.message}`));
|
|
47
|
+
};
|
|
48
|
+
ctx.on('agent/pre-step', async ({ agent }, next) => {
|
|
49
|
+
const sessionId = String(agent.id);
|
|
50
|
+
// DSH only emits pre-step for running agents. Record this before any I/O
|
|
51
|
+
// so a simultaneous recycle sees the active session inside the lock.
|
|
52
|
+
active.add(sessionId);
|
|
53
|
+
try {await resolveBinding(sessionId);}
|
|
54
|
+
catch (error) {if (error.code !== 'UPGRADE_REQUIRED') throw error;}
|
|
55
|
+
return next();
|
|
56
|
+
});
|
|
57
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
58
|
+
const sessionId = String(agent.id);
|
|
59
|
+
if (status === 'running') active.add(sessionId);
|
|
60
|
+
else {
|
|
61
|
+
active.delete(sessionId);
|
|
62
|
+
disconnect(sessionId);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
66
|
+
const sessionId = String(agent.id);
|
|
67
|
+
active.delete(sessionId);
|
|
68
|
+
disconnect(sessionId);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return { dispatch, resolveBinding, register };
|
|
72
|
+
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
// Select complete sections; the plugin does not impose a character budget.
|
|
2
|
-
export function selectReference(novel, chapterId) {
|
|
3
|
-
const current = chapterId ? novel.chapters[chapterId] : null;
|
|
4
|
-
const previous = Object.values(novel.chapters).filter(c=>!c.deleted && c.id!==current?.id && (!current || c.order<current.order))
|
|
5
|
-
.sort((a,b)=>b.order-a.order || a.id.localeCompare(b.id)).slice(0,2).reverse();
|
|
6
|
-
const items=[];
|
|
7
|
-
const add=(kind,entity)=>{
|
|
8
|
-
if (!entity?.content || entity.deleted) return;
|
|
9
|
-
items.push({kind,id:entity.id,revision:entity.revision,title:entity.title||'情节规划',content:entity.content,truncated:false});
|
|
10
|
-
};
|
|
11
|
-
for(const chapter of previous)add('previous_chapter',chapter);
|
|
12
|
-
add('current_chapter',current);
|
|
13
|
-
return {items,omitted:[],usedChars:items.reduce((sum,i)=>sum+i.content.length,0)};
|
|
14
|
-
}
|
|
15
|
-
|
|
1
|
+
// Select complete sections; the plugin does not impose a character budget.
|
|
2
|
+
export function selectReference(novel, chapterId) {
|
|
3
|
+
const current = chapterId ? novel.chapters[chapterId] : null;
|
|
4
|
+
const previous = Object.values(novel.chapters).filter(c=>!c.deleted && c.id!==current?.id && (!current || c.order<current.order))
|
|
5
|
+
.sort((a,b)=>b.order-a.order || a.id.localeCompare(b.id)).slice(0,2).reverse();
|
|
6
|
+
const items=[];
|
|
7
|
+
const add=(kind,entity)=>{
|
|
8
|
+
if (!entity?.content || entity.deleted) return;
|
|
9
|
+
items.push({kind,id:entity.id,revision:entity.revision,title:entity.title||'情节规划',content:entity.content,truncated:false});
|
|
10
|
+
};
|
|
11
|
+
for(const chapter of previous)add('previous_chapter',chapter);
|
|
12
|
+
add('current_chapter',current);
|
|
13
|
+
return {items,omitted:[],usedChars:items.reduce((sum,i)=>sum+i.content.length,0)};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
16
|
export function renderReference(material) {
|
|
17
17
|
const names={previous_chapter:'前文章节',current_chapter:'当前参考章节',memory:'按需读取的设定'};
|
|
18
18
|
const sections=material.items.map(item=>`## ${names[item.kind]||'参考资料'}:${item.title}\n[ID: ${item.id} | 版本: ${item.revision}${item.truncated?` | 已截断,仅含${item.position||'部分'}片段,可用工具读取完整内容`:''}]\n\n${item.content}`);
|