shennian 0.2.6 → 0.2.8
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/dist/bin/shennian.d.ts +2 -0
- package/dist/bin/shennian.js +2 -0
- package/dist/src/agents/adapter.d.ts +35 -0
- package/dist/src/agents/adapter.js +19 -0
- package/dist/src/agents/claude.d.ts +25 -0
- package/dist/src/agents/claude.js +234 -0
- package/dist/src/agents/codex.d.ts +52 -0
- package/dist/src/agents/codex.js +829 -0
- package/dist/src/agents/command-spec.d.ts +36 -0
- package/dist/src/agents/command-spec.js +303 -0
- package/dist/src/agents/cursor.d.ts +22 -0
- package/dist/src/agents/cursor.js +248 -0
- package/dist/src/agents/custom.d.ts +30 -0
- package/dist/src/agents/custom.js +210 -0
- package/dist/src/agents/detect.d.ts +9 -0
- package/dist/src/agents/detect.js +46 -0
- package/dist/src/agents/gemini.d.ts +17 -0
- package/dist/src/agents/gemini.js +172 -0
- package/dist/src/agents/model-registry/cache.d.ts +4 -0
- package/dist/src/agents/model-registry/cache.js +37 -0
- package/dist/src/agents/model-registry/discovery.d.ts +4 -0
- package/dist/src/agents/model-registry/discovery.js +166 -0
- package/dist/src/agents/model-registry/parsers.d.ts +11 -0
- package/dist/src/agents/model-registry/parsers.js +349 -0
- package/dist/src/agents/model-registry/runner.d.ts +6 -0
- package/dist/src/agents/model-registry/runner.js +29 -0
- package/dist/src/agents/model-registry/service.d.ts +6 -0
- package/dist/src/agents/model-registry/service.js +71 -0
- package/dist/src/agents/model-registry/types.d.ts +30 -0
- package/dist/src/agents/model-registry/types.js +8 -0
- package/dist/src/agents/model-registry.d.ts +13 -0
- package/dist/src/agents/model-registry.js +15 -0
- package/dist/src/agents/openclaw.d.ts +20 -0
- package/dist/src/agents/openclaw.js +273 -0
- package/dist/src/agents/pi.d.ts +51 -0
- package/dist/src/agents/pi.js +793 -0
- package/dist/src/commands/agent.d.ts +2 -0
- package/dist/src/commands/agent.js +131 -0
- package/dist/src/commands/daemon.d.ts +35 -0
- package/dist/src/commands/daemon.js +612 -0
- package/dist/src/commands/pair-qr.d.ts +4 -0
- package/dist/src/commands/pair-qr.js +6 -0
- package/dist/src/commands/pair.d.ts +8 -0
- package/dist/src/commands/pair.js +162 -0
- package/dist/src/commands/upgrade.d.ts +5 -0
- package/dist/src/commands/upgrade.js +145 -0
- package/dist/src/config/index.d.ts +32 -0
- package/dist/src/config/index.js +31 -0
- package/dist/src/fs/handler.d.ts +26 -0
- package/dist/src/fs/handler.js +130 -0
- package/dist/src/fs/security.d.ts +2 -0
- package/dist/src/fs/security.js +32 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +277 -0
- package/dist/src/log-reporter.d.ts +18 -0
- package/dist/src/log-reporter.js +16 -0
- package/dist/src/native-fusion/parsers.d.ts +12 -0
- package/dist/src/native-fusion/parsers.js +687 -0
- package/dist/src/native-fusion/service.d.ts +25 -0
- package/dist/src/native-fusion/service.js +176 -0
- package/dist/src/native-fusion/state.d.ts +3 -0
- package/dist/src/native-fusion/state.js +22 -0
- package/dist/src/native-fusion/types.d.ts +24 -0
- package/dist/src/native-fusion/types.js +1 -0
- package/dist/src/region.d.ts +15 -0
- package/dist/src/region.js +99 -0
- package/dist/src/relay/client.d.ts +62 -0
- package/dist/src/relay/client.js +311 -0
- package/dist/src/session/handlers/agents.d.ts +4 -0
- package/dist/src/session/handlers/agents.js +55 -0
- package/dist/src/session/handlers/chat.d.ts +4 -0
- package/dist/src/session/handlers/chat.js +326 -0
- package/dist/src/session/handlers/control.d.ts +5 -0
- package/dist/src/session/handlers/control.js +57 -0
- package/dist/src/session/handlers/fs.d.ts +10 -0
- package/dist/src/session/handlers/fs.js +256 -0
- package/dist/src/session/manager.d.ts +28 -0
- package/dist/src/session/manager.js +174 -0
- package/dist/src/session/store.d.ts +7 -0
- package/dist/src/session/store.js +46 -0
- package/dist/src/session/types.d.ts +35 -0
- package/dist/src/session/types.js +4 -0
- package/dist/src/upgrade/engine.d.ts +65 -0
- package/dist/src/upgrade/engine.js +239 -0
- package/package.json +2 -2
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
// @arch docs/architecture/cli/agent-adapters.md#pi-agent-上下文管理
|
|
2
|
+
// @test src/__tests__/pi-context.test.ts
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { Agent, streamProxy } from '@mariozechner/pi-agent-core';
|
|
9
|
+
import { Type } from '@sinclair/typebox';
|
|
10
|
+
import { AgentAdapter, registerAgent } from './adapter.js';
|
|
11
|
+
import { loadConfig, resolveShennianPath } from '../config/index.js';
|
|
12
|
+
import { SERVERS } from '../region.js';
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const PI_DEFAULT_MODEL_ID = 'qwen3.6-plus';
|
|
15
|
+
function createPiModel(modelId = PI_DEFAULT_MODEL_ID) {
|
|
16
|
+
return {
|
|
17
|
+
id: modelId,
|
|
18
|
+
name: modelId,
|
|
19
|
+
api: 'openai-completions',
|
|
20
|
+
provider: 'shennian-proxy',
|
|
21
|
+
baseUrl: '',
|
|
22
|
+
reasoning: false,
|
|
23
|
+
input: ['text', 'image'],
|
|
24
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
25
|
+
contextWindow: 128000,
|
|
26
|
+
maxTokens: 8192,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const SYSTEM_PROMPT = `你是神念内置编程助手,运行在用户本地机器上。
|
|
30
|
+
你可以读写文件、执行 shell 命令、帮助用户完成编程和系统管理任务。
|
|
31
|
+
工作目录已设置,操作文件时使用相对路径或绝对路径均可。
|
|
32
|
+
保持回复简洁、准确,中文回复。`;
|
|
33
|
+
// ── Context compression ──────────────────────────────────────────────────────
|
|
34
|
+
const CONTEXT_TOKEN_THRESHOLD = 90_000;
|
|
35
|
+
const KEEP_RECENT_MESSAGES = 6;
|
|
36
|
+
const SESSIONS_DIR = resolveShennianPath('sessions');
|
|
37
|
+
const SUMMARY_FILENAME = 'summary.json';
|
|
38
|
+
const SNAPSHOT_FILENAME = 'snapshot.json';
|
|
39
|
+
const MESSAGES_FILENAME = 'messages.jsonl';
|
|
40
|
+
const LEGACY_SUMMARY_FILENAME = 'pi-context.json';
|
|
41
|
+
function estimateTokens(messages) {
|
|
42
|
+
let chars = 0;
|
|
43
|
+
for (const msg of messages)
|
|
44
|
+
chars += JSON.stringify(msg).length;
|
|
45
|
+
return Math.ceil(chars / 3);
|
|
46
|
+
}
|
|
47
|
+
function messagesToText(messages) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
for (const msg of messages) {
|
|
50
|
+
const m = msg;
|
|
51
|
+
const role = String(m.role ?? 'unknown');
|
|
52
|
+
if (!Array.isArray(m.content))
|
|
53
|
+
continue;
|
|
54
|
+
for (const c of m.content) {
|
|
55
|
+
if (c.type === 'text' && typeof c.text === 'string') {
|
|
56
|
+
lines.push(`[${role}] ${c.text.slice(0, 500)}`);
|
|
57
|
+
}
|
|
58
|
+
else if (c.type === 'toolCall') {
|
|
59
|
+
const name = typeof c.name === 'string' ? c.name : typeof c.toolName === 'string' ? c.toolName : 'tool';
|
|
60
|
+
const args = c.arguments ?? c.args ?? {};
|
|
61
|
+
lines.push(`[${role}] tool:${name}(${JSON.stringify(args).slice(0, 200)})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
67
|
+
function cloneMessages(messages) {
|
|
68
|
+
return JSON.parse(JSON.stringify(messages));
|
|
69
|
+
}
|
|
70
|
+
function getSessionDir(sessionId) {
|
|
71
|
+
return path.join(SESSIONS_DIR, sessionId);
|
|
72
|
+
}
|
|
73
|
+
function getMessageContentText(content) {
|
|
74
|
+
if (!content || typeof content !== 'object')
|
|
75
|
+
return null;
|
|
76
|
+
const chunk = content;
|
|
77
|
+
if (chunk.type === 'text' && typeof chunk.text === 'string')
|
|
78
|
+
return chunk.text;
|
|
79
|
+
if (chunk.type === 'thinking' && typeof chunk.thinking === 'string')
|
|
80
|
+
return `[thinking] ${chunk.thinking}`;
|
|
81
|
+
if (chunk.type === 'toolCall') {
|
|
82
|
+
const name = typeof chunk.name === 'string'
|
|
83
|
+
? chunk.name
|
|
84
|
+
: typeof chunk.toolName === 'string'
|
|
85
|
+
? chunk.toolName
|
|
86
|
+
: 'tool';
|
|
87
|
+
const args = chunk.arguments ?? chunk.args ?? {};
|
|
88
|
+
return `[toolCall] ${name}(${JSON.stringify(args).slice(0, 300)})`;
|
|
89
|
+
}
|
|
90
|
+
if (chunk.type === 'image' && typeof chunk.mimeType === 'string') {
|
|
91
|
+
return `[image] ${chunk.mimeType}`;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function buildRollingSummary(cachedSummary, messages) {
|
|
96
|
+
const recentLines = [];
|
|
97
|
+
for (const msg of messages) {
|
|
98
|
+
const role = String(msg.role ?? 'unknown');
|
|
99
|
+
const content = Array.isArray(msg.content)
|
|
100
|
+
? msg.content
|
|
101
|
+
: [];
|
|
102
|
+
for (const chunk of content) {
|
|
103
|
+
const text = getMessageContentText(chunk);
|
|
104
|
+
if (text)
|
|
105
|
+
recentLines.push(`[${role}] ${text}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const recent = recentLines.slice(-8).join('\n').trim();
|
|
109
|
+
const summary = recent || cachedSummary?.trim() || null;
|
|
110
|
+
if (!summary)
|
|
111
|
+
return null;
|
|
112
|
+
return summary.length > 2000 ? summary.slice(summary.length - 2000) : summary;
|
|
113
|
+
}
|
|
114
|
+
function longestCommonPrefixLength(left, right) {
|
|
115
|
+
const max = Math.min(left.length, right.length);
|
|
116
|
+
for (let i = 0; i < max; i++) {
|
|
117
|
+
if (JSON.stringify(left[i]) !== JSON.stringify(right[i]))
|
|
118
|
+
return i;
|
|
119
|
+
}
|
|
120
|
+
return max;
|
|
121
|
+
}
|
|
122
|
+
async function requestProxySummary(proxyUrl, authToken, prompt) {
|
|
123
|
+
const response = await fetch(`${proxyUrl}/api/stream`, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
Authorization: `Bearer ${authToken}`,
|
|
128
|
+
},
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
model: createPiModel('qwen-plus'),
|
|
131
|
+
context: {
|
|
132
|
+
messages: [
|
|
133
|
+
{
|
|
134
|
+
role: 'user',
|
|
135
|
+
content: [{ type: 'text', text: prompt }],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
options: {
|
|
140
|
+
temperature: 0.3,
|
|
141
|
+
maxTokens: 500,
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok || !response.body)
|
|
146
|
+
return null;
|
|
147
|
+
const reader = response.body.getReader();
|
|
148
|
+
const decoder = new TextDecoder();
|
|
149
|
+
const parts = [];
|
|
150
|
+
let buffer = '';
|
|
151
|
+
while (true) {
|
|
152
|
+
const { done, value } = await reader.read();
|
|
153
|
+
if (done)
|
|
154
|
+
break;
|
|
155
|
+
buffer += decoder.decode(value, { stream: true });
|
|
156
|
+
const lines = buffer.split('\n');
|
|
157
|
+
buffer = lines.pop() ?? '';
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
if (!line.startsWith('data: '))
|
|
160
|
+
continue;
|
|
161
|
+
const raw = line.slice(6).trim();
|
|
162
|
+
if (!raw)
|
|
163
|
+
continue;
|
|
164
|
+
const event = JSON.parse(raw);
|
|
165
|
+
if (event.type === 'text_delta' && typeof event.delta === 'string') {
|
|
166
|
+
parts.push(event.delta);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return parts.join('').trim() || null;
|
|
171
|
+
}
|
|
172
|
+
// ── Local tools ───────────────────────────────────────────────────────────────
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
174
|
+
function makeTools(workDir) {
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
name: 'read_file',
|
|
178
|
+
label: '读取文件',
|
|
179
|
+
description: 'Read the full content of a file at the given path.',
|
|
180
|
+
parameters: Type.Object({
|
|
181
|
+
path: Type.String({ description: 'Absolute or relative file path' }),
|
|
182
|
+
}),
|
|
183
|
+
async execute(_id, { path: filePath }) {
|
|
184
|
+
const resolved = path.resolve(workDir, filePath);
|
|
185
|
+
try {
|
|
186
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: 'text', text: content }],
|
|
189
|
+
details: { path: resolved },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: 'text',
|
|
197
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
details: { path: resolved },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'write_file',
|
|
207
|
+
label: '写入文件',
|
|
208
|
+
description: 'Write content to a file, creating it or overwriting if it exists.',
|
|
209
|
+
parameters: Type.Object({
|
|
210
|
+
path: Type.String({ description: 'Absolute or relative file path' }),
|
|
211
|
+
content: Type.String({ description: 'File content to write' }),
|
|
212
|
+
}),
|
|
213
|
+
async execute(_id, { path: filePath, content }) {
|
|
214
|
+
const resolved = path.resolve(workDir, filePath);
|
|
215
|
+
try {
|
|
216
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
217
|
+
fs.writeFileSync(resolved, content, 'utf-8');
|
|
218
|
+
return {
|
|
219
|
+
content: [
|
|
220
|
+
{ type: 'text', text: `Written ${content.length} bytes to ${resolved}` },
|
|
221
|
+
],
|
|
222
|
+
details: { path: resolved },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
{
|
|
229
|
+
type: 'text',
|
|
230
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
details: { path: resolved },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'list_directory',
|
|
240
|
+
label: '列出目录',
|
|
241
|
+
description: 'List files and directories at the given path.',
|
|
242
|
+
parameters: Type.Object({
|
|
243
|
+
path: Type.String({ description: 'Directory path to list', default: '.' }),
|
|
244
|
+
}),
|
|
245
|
+
async execute(_id, { path: dirPath }) {
|
|
246
|
+
const resolved = path.resolve(workDir, dirPath);
|
|
247
|
+
try {
|
|
248
|
+
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
249
|
+
const lines = entries.map((e) => `${e.isDirectory() ? 'd' : 'f'} ${e.name}`).join('\n');
|
|
250
|
+
return {
|
|
251
|
+
content: [{ type: 'text', text: lines || '(empty)' }],
|
|
252
|
+
details: { path: resolved },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
return {
|
|
257
|
+
content: [
|
|
258
|
+
{
|
|
259
|
+
type: 'text',
|
|
260
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
details: { path: resolved },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'bash',
|
|
270
|
+
label: '执行命令',
|
|
271
|
+
description: 'Execute a shell command in the working directory. Timeout: 30s.',
|
|
272
|
+
parameters: Type.Object({
|
|
273
|
+
command: Type.String({ description: 'Shell command to execute' }),
|
|
274
|
+
}),
|
|
275
|
+
async execute(_id, { command }, signal) {
|
|
276
|
+
try {
|
|
277
|
+
const { stdout, stderr } = await execFileAsync('bash', ['-c', command], {
|
|
278
|
+
cwd: workDir,
|
|
279
|
+
timeout: 30_000,
|
|
280
|
+
signal,
|
|
281
|
+
maxBuffer: 1024 * 1024,
|
|
282
|
+
});
|
|
283
|
+
const output = [stdout, stderr].filter(Boolean).join('\n---stderr---\n');
|
|
284
|
+
return {
|
|
285
|
+
content: [{ type: 'text', text: output || '(no output)' }],
|
|
286
|
+
details: { command },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
291
|
+
return {
|
|
292
|
+
content: [{ type: 'text', text: `Error: ${msg}` }],
|
|
293
|
+
details: { command },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: 'read_image',
|
|
300
|
+
label: '读取图片',
|
|
301
|
+
description: 'Read an image file and return its content for visual analysis. Supports png, jpg, jpeg, gif, webp. Max 10 MB.',
|
|
302
|
+
parameters: Type.Object({
|
|
303
|
+
path: Type.String({ description: 'Absolute or relative path to the image file' }),
|
|
304
|
+
}),
|
|
305
|
+
async execute(_id, { path: filePath }) {
|
|
306
|
+
const resolved = path.resolve(workDir, filePath);
|
|
307
|
+
const ext = path.extname(resolved).toLowerCase().slice(1);
|
|
308
|
+
const mimeMap = {
|
|
309
|
+
png: 'image/png',
|
|
310
|
+
jpg: 'image/jpeg',
|
|
311
|
+
jpeg: 'image/jpeg',
|
|
312
|
+
gif: 'image/gif',
|
|
313
|
+
webp: 'image/webp',
|
|
314
|
+
};
|
|
315
|
+
const mimeType = mimeMap[ext];
|
|
316
|
+
if (!mimeType) {
|
|
317
|
+
return {
|
|
318
|
+
content: [
|
|
319
|
+
{
|
|
320
|
+
type: 'text',
|
|
321
|
+
text: `Error: unsupported image type ".${ext}". Supported: png, jpg, jpeg, gif, webp`,
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
details: { path: resolved },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const stat = fs.statSync(resolved);
|
|
329
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
330
|
+
return {
|
|
331
|
+
content: [
|
|
332
|
+
{
|
|
333
|
+
type: 'text',
|
|
334
|
+
text: `Error: image too large (${(stat.size / 1024 / 1024).toFixed(1)} MB, max 10 MB)`,
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
details: { path: resolved },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const data = fs.readFileSync(resolved).toString('base64');
|
|
341
|
+
const imageContent = { type: 'image', data, mimeType };
|
|
342
|
+
return { content: [imageContent], details: { path: resolved, mimeType, size: stat.size } };
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
return {
|
|
346
|
+
content: [
|
|
347
|
+
{
|
|
348
|
+
type: 'text',
|
|
349
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
details: { path: resolved },
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
}
|
|
359
|
+
// ── PiAdapter ─────────────────────────────────────────────────────────────────
|
|
360
|
+
export class PiAdapter extends AgentAdapter {
|
|
361
|
+
type = 'pi';
|
|
362
|
+
agent = null;
|
|
363
|
+
sessionId = null;
|
|
364
|
+
workDir = null;
|
|
365
|
+
seq = 0;
|
|
366
|
+
runId = '';
|
|
367
|
+
emittedLengths = new Map();
|
|
368
|
+
terminalState = 'open';
|
|
369
|
+
authToken = null;
|
|
370
|
+
proxyUrl = null;
|
|
371
|
+
sessionDir = null;
|
|
372
|
+
messagesPath = null;
|
|
373
|
+
snapshotPath = null;
|
|
374
|
+
summaryPath = null;
|
|
375
|
+
cachedSummary = null;
|
|
376
|
+
restoredMessages = [];
|
|
377
|
+
compressing = false;
|
|
378
|
+
lastSummaryMsgCount = 0;
|
|
379
|
+
pendingBaseMessages = [];
|
|
380
|
+
finalizePromise = Promise.resolve();
|
|
381
|
+
pendingSendStart = null;
|
|
382
|
+
async start(sessionId, workDir, _agentSessionId) {
|
|
383
|
+
this.sessionId = sessionId;
|
|
384
|
+
this.workDir = workDir;
|
|
385
|
+
this.seq = 0;
|
|
386
|
+
this.sessionDir = getSessionDir(sessionId);
|
|
387
|
+
this.messagesPath = path.join(this.sessionDir, MESSAGES_FILENAME);
|
|
388
|
+
this.snapshotPath = path.join(this.sessionDir, SNAPSHOT_FILENAME);
|
|
389
|
+
this.summaryPath = path.join(this.sessionDir, SUMMARY_FILENAME);
|
|
390
|
+
this.loadSnapshot();
|
|
391
|
+
this.loadSummary();
|
|
392
|
+
}
|
|
393
|
+
async send(text, modelId) {
|
|
394
|
+
this.agent?.abort();
|
|
395
|
+
const config = loadConfig();
|
|
396
|
+
const authToken = config.machineToken ?? config.accessToken;
|
|
397
|
+
if (!authToken) {
|
|
398
|
+
this.emit('agentEvent', {
|
|
399
|
+
state: 'error',
|
|
400
|
+
runId: this.runId,
|
|
401
|
+
seq: ++this.seq,
|
|
402
|
+
message: '未配对,请先运行 shennian 完成机器配对',
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
this.authToken = authToken;
|
|
407
|
+
this.proxyUrl = (config.serverUrl ?? SERVERS.global.url).replace(/\/$/, '');
|
|
408
|
+
this.runId = randomUUID();
|
|
409
|
+
this.seq = 0;
|
|
410
|
+
this.emittedLengths.clear();
|
|
411
|
+
this.terminalState = 'open';
|
|
412
|
+
if (!this.agent) {
|
|
413
|
+
this.initAgent();
|
|
414
|
+
}
|
|
415
|
+
await this.finalizePromise.catch(() => { });
|
|
416
|
+
this.agent?.setModel(createPiModel(modelId ?? PI_DEFAULT_MODEL_ID));
|
|
417
|
+
this.pendingBaseMessages = cloneMessages(this.agent?.state.messages ?? []);
|
|
418
|
+
const runId = this.runId;
|
|
419
|
+
const sendAccepted = new Promise((resolve, reject) => {
|
|
420
|
+
this.pendingSendStart = { runId, resolve, reject };
|
|
421
|
+
});
|
|
422
|
+
this.emit('agentEvent', { state: 'init', runId, seq: ++this.seq });
|
|
423
|
+
void this.agent.prompt(text)
|
|
424
|
+
.then(async () => {
|
|
425
|
+
this.resolvePendingSendStart(runId);
|
|
426
|
+
await this.finalizePromise.catch(() => { });
|
|
427
|
+
})
|
|
428
|
+
.catch((err) => {
|
|
429
|
+
this.rejectPendingSendStart(runId, err);
|
|
430
|
+
if (this.terminalState !== 'open')
|
|
431
|
+
return;
|
|
432
|
+
this.terminalState = 'error';
|
|
433
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
434
|
+
if (message.includes('429') || message.includes('daily_quota_exceeded')) {
|
|
435
|
+
this.emit('agentEvent', {
|
|
436
|
+
state: 'error',
|
|
437
|
+
runId,
|
|
438
|
+
seq: ++this.seq,
|
|
439
|
+
message: '今日免费额度已用完,可在 ~/.shennian/config.json 中配置 apiKey 字段(兼容 OpenAI 格式)继续使用。',
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
this.emit('agentEvent', {
|
|
444
|
+
state: 'error',
|
|
445
|
+
runId,
|
|
446
|
+
seq: ++this.seq,
|
|
447
|
+
message,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
await sendAccepted;
|
|
452
|
+
}
|
|
453
|
+
// ── Agent lifecycle ──────────────────────────────────────────────────────────
|
|
454
|
+
initAgent() {
|
|
455
|
+
const workDir = this.workDir ?? process.cwd();
|
|
456
|
+
const tools = makeTools(workDir);
|
|
457
|
+
const agent = new Agent({
|
|
458
|
+
initialState: {
|
|
459
|
+
systemPrompt: SYSTEM_PROMPT + `\n\n当前工作目录:${workDir}`,
|
|
460
|
+
model: createPiModel(),
|
|
461
|
+
tools,
|
|
462
|
+
},
|
|
463
|
+
streamFn: (model, context, options) => streamProxy(model, context, {
|
|
464
|
+
...options,
|
|
465
|
+
authToken: this.authToken,
|
|
466
|
+
proxyUrl: this.proxyUrl,
|
|
467
|
+
}),
|
|
468
|
+
transformContext: (messages) => this.compressContext(messages),
|
|
469
|
+
});
|
|
470
|
+
this.agent = agent;
|
|
471
|
+
if (this.restoredMessages.length > 0) {
|
|
472
|
+
agent.replaceMessages(cloneMessages(this.restoredMessages));
|
|
473
|
+
}
|
|
474
|
+
else if (this.cachedSummary) {
|
|
475
|
+
agent.appendMessage({
|
|
476
|
+
role: 'user',
|
|
477
|
+
content: [{ type: 'text', text: `[之前的对话摘要]\n${this.cachedSummary}` }],
|
|
478
|
+
timestamp: Date.now(),
|
|
479
|
+
});
|
|
480
|
+
agent.appendMessage({
|
|
481
|
+
role: 'assistant',
|
|
482
|
+
content: [{ type: 'text', text: '好的,我已了解之前的对话上下文,请继续。' }],
|
|
483
|
+
timestamp: Date.now(),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
agent.subscribe((evt) => {
|
|
487
|
+
if (this.terminalState !== 'open')
|
|
488
|
+
return;
|
|
489
|
+
const seq = ++this.seq;
|
|
490
|
+
const runId = this.runId;
|
|
491
|
+
switch (evt.type) {
|
|
492
|
+
case 'agent_start':
|
|
493
|
+
this.resolvePendingSendStart(runId);
|
|
494
|
+
this.emit('agentEvent', { state: 'start', runId, seq });
|
|
495
|
+
break;
|
|
496
|
+
case 'message_update': {
|
|
497
|
+
const msg = evt.message;
|
|
498
|
+
if (msg.role !== 'assistant')
|
|
499
|
+
break;
|
|
500
|
+
const content = msg.content ?? [];
|
|
501
|
+
for (let i = 0; i < content.length; i++) {
|
|
502
|
+
const c = content[i];
|
|
503
|
+
if (c.type === 'text' && c.text) {
|
|
504
|
+
const key = `text:${i}`;
|
|
505
|
+
const prev = this.emittedLengths.get(key) ?? 0;
|
|
506
|
+
if (c.text.length > prev) {
|
|
507
|
+
const delta = c.text.slice(prev);
|
|
508
|
+
this.emittedLengths.set(key, c.text.length);
|
|
509
|
+
this.emit('agentEvent', { state: 'delta', runId, seq, text: delta });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
else if (c.type === 'thinking' && c.thinking) {
|
|
513
|
+
const key = `thinking:${i}`;
|
|
514
|
+
const prev = this.emittedLengths.get(key) ?? 0;
|
|
515
|
+
if (c.thinking.length > prev) {
|
|
516
|
+
const delta = c.thinking.slice(prev);
|
|
517
|
+
this.emittedLengths.set(key, c.thinking.length);
|
|
518
|
+
this.emit('agentEvent', { state: 'delta', runId, seq, text: delta, thinking: true });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case 'tool_execution_start':
|
|
525
|
+
this.emit('agentEvent', {
|
|
526
|
+
state: 'tool-call',
|
|
527
|
+
runId,
|
|
528
|
+
seq,
|
|
529
|
+
name: evt.toolName,
|
|
530
|
+
args: evt.args,
|
|
531
|
+
});
|
|
532
|
+
break;
|
|
533
|
+
case 'tool_execution_end':
|
|
534
|
+
this.emit('agentEvent', {
|
|
535
|
+
state: 'tool-result',
|
|
536
|
+
runId,
|
|
537
|
+
seq,
|
|
538
|
+
name: evt.toolName,
|
|
539
|
+
result: typeof evt.result === 'string' ? evt.result : JSON.stringify(evt.result),
|
|
540
|
+
});
|
|
541
|
+
break;
|
|
542
|
+
case 'agent_end': {
|
|
543
|
+
this.terminalState = 'final';
|
|
544
|
+
const msgs = evt.messages;
|
|
545
|
+
const lastAssistant = [...msgs].reverse().find((m) => m.role === 'assistant');
|
|
546
|
+
const usage = lastAssistant?.usage;
|
|
547
|
+
this.emit('agentEvent', {
|
|
548
|
+
state: 'final',
|
|
549
|
+
runId,
|
|
550
|
+
seq,
|
|
551
|
+
usage: usage
|
|
552
|
+
? {
|
|
553
|
+
inputTokens: usage.input ?? 0,
|
|
554
|
+
outputTokens: usage.output ?? 0,
|
|
555
|
+
}
|
|
556
|
+
: undefined,
|
|
557
|
+
});
|
|
558
|
+
this.finalizePromise = this.finalizeTurn(msgs);
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
// ── Context compression ────────────────────────────────────────────────────
|
|
565
|
+
/**
|
|
566
|
+
* After each turn, compress old messages and actually replace them in the
|
|
567
|
+
* Agent's internal state. This is the primary compression mechanism.
|
|
568
|
+
*/
|
|
569
|
+
async compressAndReplace() {
|
|
570
|
+
if (!this.agent || this.compressing)
|
|
571
|
+
return;
|
|
572
|
+
const messages = this.agent.state.messages;
|
|
573
|
+
const tokens = estimateTokens(messages);
|
|
574
|
+
if (tokens < CONTEXT_TOKEN_THRESHOLD)
|
|
575
|
+
return;
|
|
576
|
+
const split = this.splitMessages(messages);
|
|
577
|
+
if (!split)
|
|
578
|
+
return;
|
|
579
|
+
const summary = await this.generateSummary(split.toCompress);
|
|
580
|
+
if (!summary)
|
|
581
|
+
return;
|
|
582
|
+
this.cachedSummary = summary;
|
|
583
|
+
this.lastSummaryMsgCount += split.toCompress.length;
|
|
584
|
+
const replacement = [
|
|
585
|
+
{
|
|
586
|
+
role: 'user',
|
|
587
|
+
content: [{ type: 'text', text: `[历史对话摘要]\n${summary}` }],
|
|
588
|
+
timestamp: Date.now(),
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
role: 'assistant',
|
|
592
|
+
content: [{ type: 'text', text: '好的,我已了解上下文。' }],
|
|
593
|
+
timestamp: Date.now(),
|
|
594
|
+
},
|
|
595
|
+
...split.toKeep,
|
|
596
|
+
];
|
|
597
|
+
this.agent.replaceMessages(replacement);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Safety fallback: if messages somehow exceed the threshold at LLM call time
|
|
601
|
+
* (e.g. compressAndReplace hasn't run yet), return a truncated view using
|
|
602
|
+
* the cached summary. Does NOT call LLM — avoids nested async delays.
|
|
603
|
+
*/
|
|
604
|
+
async compressContext(messages) {
|
|
605
|
+
const tokens = estimateTokens(messages);
|
|
606
|
+
if (tokens < CONTEXT_TOKEN_THRESHOLD)
|
|
607
|
+
return messages;
|
|
608
|
+
if (!this.cachedSummary)
|
|
609
|
+
return messages;
|
|
610
|
+
const split = this.splitMessages(messages);
|
|
611
|
+
if (!split)
|
|
612
|
+
return messages;
|
|
613
|
+
return [
|
|
614
|
+
{
|
|
615
|
+
role: 'user',
|
|
616
|
+
content: [{ type: 'text', text: `[历史对话摘要]\n${this.cachedSummary}` }],
|
|
617
|
+
timestamp: Date.now(),
|
|
618
|
+
},
|
|
619
|
+
...split.toKeep,
|
|
620
|
+
];
|
|
621
|
+
}
|
|
622
|
+
splitMessages(messages) {
|
|
623
|
+
const nonSystem = messages.filter((m) => m.role !== 'system');
|
|
624
|
+
if (nonSystem.length <= KEEP_RECENT_MESSAGES)
|
|
625
|
+
return null;
|
|
626
|
+
let splitIdx = nonSystem.length - KEEP_RECENT_MESSAGES;
|
|
627
|
+
while (splitIdx > 0 &&
|
|
628
|
+
nonSystem[splitIdx].role === 'toolResult') {
|
|
629
|
+
splitIdx--;
|
|
630
|
+
}
|
|
631
|
+
if (splitIdx <= 0)
|
|
632
|
+
return null;
|
|
633
|
+
return { toCompress: nonSystem.slice(0, splitIdx), toKeep: nonSystem.slice(splitIdx) };
|
|
634
|
+
}
|
|
635
|
+
async generateSummary(messages) {
|
|
636
|
+
if (!this.authToken || !this.proxyUrl || this.compressing)
|
|
637
|
+
return this.cachedSummary;
|
|
638
|
+
this.compressing = true;
|
|
639
|
+
try {
|
|
640
|
+
const text = messagesToText(messages);
|
|
641
|
+
if (text.length < 100)
|
|
642
|
+
return null;
|
|
643
|
+
const truncated = text.length > 10000 ? text.slice(0, 10000) + '\n...(已截断)' : text;
|
|
644
|
+
return await requestProxySummary(this.proxyUrl, this.authToken, `请将以下对话历史压缩为简洁摘要,保留关键信息:讨论的文件和目录、做出的技术决定、当前任务进度、未完成的工作。摘要控制在 300 字以内。\n\n${truncated}`);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return this.cachedSummary;
|
|
648
|
+
}
|
|
649
|
+
finally {
|
|
650
|
+
this.compressing = false;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// ── Summary persistence ────────────────────────────────────────────────────
|
|
654
|
+
loadSnapshot() {
|
|
655
|
+
if (!this.snapshotPath)
|
|
656
|
+
return;
|
|
657
|
+
try {
|
|
658
|
+
const raw = fs.readFileSync(this.snapshotPath, 'utf-8');
|
|
659
|
+
const data = JSON.parse(raw);
|
|
660
|
+
this.restoredMessages = Array.isArray(data.messages) ? data.messages : [];
|
|
661
|
+
this.cachedSummary = data.summary ?? this.cachedSummary;
|
|
662
|
+
this.lastSummaryMsgCount = data.summarizedCount ?? this.lastSummaryMsgCount;
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
this.restoredMessages = [];
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
loadSummary() {
|
|
669
|
+
if (!this.summaryPath)
|
|
670
|
+
return;
|
|
671
|
+
try {
|
|
672
|
+
const raw = fs.readFileSync(this.summaryPath, 'utf-8');
|
|
673
|
+
const data = JSON.parse(raw);
|
|
674
|
+
this.cachedSummary = data.summary ?? null;
|
|
675
|
+
this.lastSummaryMsgCount = data.summarizedCount ?? 0;
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
if (!this.sessionDir)
|
|
679
|
+
return;
|
|
680
|
+
try {
|
|
681
|
+
const legacyPath = path.join(this.sessionDir, LEGACY_SUMMARY_FILENAME);
|
|
682
|
+
const raw = fs.readFileSync(legacyPath, 'utf-8');
|
|
683
|
+
const data = JSON.parse(raw);
|
|
684
|
+
this.cachedSummary = data.summary ?? null;
|
|
685
|
+
this.lastSummaryMsgCount = data.summarizedCount ?? 0;
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
// File doesn't exist or is corrupt — start fresh
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
saveSummary(messages) {
|
|
693
|
+
if (!this.summaryPath)
|
|
694
|
+
return;
|
|
695
|
+
try {
|
|
696
|
+
fs.mkdirSync(path.dirname(this.summaryPath), { recursive: true });
|
|
697
|
+
const summary = buildRollingSummary(this.cachedSummary, messages);
|
|
698
|
+
this.cachedSummary = summary;
|
|
699
|
+
fs.writeFileSync(this.summaryPath, JSON.stringify({
|
|
700
|
+
version: 1,
|
|
701
|
+
summary,
|
|
702
|
+
summarizedCount: this.lastSummaryMsgCount,
|
|
703
|
+
updatedAt: Date.now(),
|
|
704
|
+
}, null, 2));
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
// Best-effort persistence
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
saveSnapshot(messages) {
|
|
711
|
+
if (!this.snapshotPath || !this.sessionId || !this.workDir)
|
|
712
|
+
return;
|
|
713
|
+
try {
|
|
714
|
+
fs.mkdirSync(path.dirname(this.snapshotPath), { recursive: true });
|
|
715
|
+
fs.writeFileSync(this.snapshotPath, JSON.stringify({
|
|
716
|
+
version: 1,
|
|
717
|
+
sessionId: this.sessionId,
|
|
718
|
+
workDir: this.workDir,
|
|
719
|
+
summary: this.cachedSummary,
|
|
720
|
+
summarizedCount: this.lastSummaryMsgCount,
|
|
721
|
+
messages,
|
|
722
|
+
updatedAt: Date.now(),
|
|
723
|
+
}, null, 2));
|
|
724
|
+
this.restoredMessages = cloneMessages(messages);
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
// Best-effort persistence
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
appendMessagesLog(messages) {
|
|
731
|
+
if (!this.messagesPath || messages.length === 0)
|
|
732
|
+
return;
|
|
733
|
+
try {
|
|
734
|
+
fs.mkdirSync(path.dirname(this.messagesPath), { recursive: true });
|
|
735
|
+
const lines = messages.map((message) => JSON.stringify(message)).join('\n') + '\n';
|
|
736
|
+
fs.appendFileSync(this.messagesPath, lines, 'utf-8');
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Best-effort persistence
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
getTurnMessages(finalMessages) {
|
|
743
|
+
const prefixLength = longestCommonPrefixLength(this.pendingBaseMessages, finalMessages);
|
|
744
|
+
return cloneMessages(finalMessages.slice(prefixLength));
|
|
745
|
+
}
|
|
746
|
+
async finalizeTurn(finalMessages) {
|
|
747
|
+
const clonedFinalMessages = cloneMessages(finalMessages);
|
|
748
|
+
const newMessages = this.getTurnMessages(clonedFinalMessages);
|
|
749
|
+
this.appendMessagesLog(newMessages);
|
|
750
|
+
await this.compressAndReplace();
|
|
751
|
+
const currentMessages = this.agent
|
|
752
|
+
? cloneMessages(this.agent.state.messages)
|
|
753
|
+
: clonedFinalMessages;
|
|
754
|
+
if (!this.cachedSummary) {
|
|
755
|
+
this.cachedSummary = buildRollingSummary(null, clonedFinalMessages);
|
|
756
|
+
}
|
|
757
|
+
this.saveSummary(currentMessages);
|
|
758
|
+
this.saveSnapshot(currentMessages);
|
|
759
|
+
}
|
|
760
|
+
async resume(_agentSessionId) {
|
|
761
|
+
this.loadSnapshot();
|
|
762
|
+
this.loadSummary();
|
|
763
|
+
if (this.agent && this.restoredMessages.length > 0) {
|
|
764
|
+
this.agent.replaceMessages(cloneMessages(this.restoredMessages));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
async stop() {
|
|
768
|
+
this.rejectPendingSendStart(this.runId, new Error('Pi run stopped before dispatch completed'));
|
|
769
|
+
await this.finalizePromise.catch(() => { });
|
|
770
|
+
if (this.agent) {
|
|
771
|
+
const currentMessages = cloneMessages(this.agent.state.messages ?? []);
|
|
772
|
+
this.saveSummary(currentMessages);
|
|
773
|
+
this.saveSnapshot(currentMessages);
|
|
774
|
+
}
|
|
775
|
+
this.agent?.abort();
|
|
776
|
+
this.agent = null;
|
|
777
|
+
}
|
|
778
|
+
resolvePendingSendStart(runId) {
|
|
779
|
+
if (!this.pendingSendStart || this.pendingSendStart.runId !== runId)
|
|
780
|
+
return;
|
|
781
|
+
const { resolve } = this.pendingSendStart;
|
|
782
|
+
this.pendingSendStart = null;
|
|
783
|
+
resolve();
|
|
784
|
+
}
|
|
785
|
+
rejectPendingSendStart(runId, error) {
|
|
786
|
+
if (!this.pendingSendStart || this.pendingSendStart.runId !== runId)
|
|
787
|
+
return;
|
|
788
|
+
const { reject } = this.pendingSendStart;
|
|
789
|
+
this.pendingSendStart = null;
|
|
790
|
+
reject(error);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
registerAgent('pi', () => new PiAdapter());
|