arona-agent 1.0.0
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 -0
- package/README.md +149 -0
- package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
- package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
- package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
- package/assets/blue-archive/arona/voice.mp3 +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
- package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
- package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
- package/assets/blue-archive/hoshino/voice.mp3 +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
- package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
- package/assets/blue-archive/plana/voice.mp3 +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
- package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
- package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
- package/assets/blue-archive/shiroko/voice.mp3 +0 -0
- package/bin/arona.mjs +43 -0
- package/package.json +37 -0
- package/pet/agents.cjs +197 -0
- package/pet/main.cjs +462 -0
- package/pet/preload.cjs +21 -0
- package/pet/renderer/fx.html +30 -0
- package/pet/renderer/fx.js +41 -0
- package/pet/renderer/gallery.html +18 -0
- package/pet/renderer/gallery.js +31 -0
- package/pet/renderer/index.html +18 -0
- package/pet/renderer/renderer.js +382 -0
- package/pet/renderer/spine_layer.js +609 -0
- package/pet/renderer/spinetest.html +24 -0
- package/pet/renderer/spinetest.js +295 -0
- package/pet/renderer/style.css +63 -0
- package/pet/tools/gallery_capture.cjs +267 -0
- package/pet/tools/gen_sway.cjs +170 -0
- package/pet/tools/meshify_region.cjs +105 -0
- package/pet/tools/skel_inspect.cjs +112 -0
- package/pet/tools/skel_to_json.cjs +410 -0
- package/pet/tools/spine_node.cjs +69 -0
- package/pet/tools/visual_test.cjs +262 -0
- package/pet/vendor/spine/spine-canvas.js +8472 -0
- package/pet/vendor/spine/spine-webgl.js +10839 -0
- package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-313.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts.cpython-313.pyc +0 -0
- package/python/__pycache__/tts.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
- package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
- package/python/_i18n.py +18 -0
- package/python/computer_use.py +190 -0
- package/python/hotkey.py +196 -0
- package/python/stt.py +240 -0
- package/python/tts_say.py +177 -0
- package/python/voice_clone.py +98 -0
- package/requirements.txt +6 -0
- package/src/agent.ts +791 -0
- package/src/agent_registry.ts +109 -0
- package/src/commands.ts +641 -0
- package/src/config.ts +245 -0
- package/src/in_memory_credentials.ts +61 -0
- package/src/index.ts +78 -0
- package/src/locale.ts +131 -0
- package/src/logo.ts +120 -0
- package/src/mcp.ts +192 -0
- package/src/memory.ts +303 -0
- package/src/pet.ts +346 -0
- package/src/renderer.ts +350 -0
- package/src/repl.ts +902 -0
- package/src/setup.ts +371 -0
- package/src/skills.ts +96 -0
- package/src/slash_menu.ts +371 -0
- package/src/slash_registry.ts +53 -0
- package/src/speaker_context.ts +41 -0
- package/src/text_split.ts +79 -0
- package/src/tools/computer_use.ts +142 -0
- package/src/tools/emotion_tool.ts +55 -0
- package/src/tools/keep_silent_tool.ts +26 -0
- package/src/tools/memory_tool.ts +24 -0
- package/src/tools/skill_tools.ts +100 -0
- package/src/tools/tavily_tools.ts +431 -0
- package/src/tools/voice_tools.ts +28 -0
- package/src/tts_stream.ts +260 -0
- package/src/tui_select.ts +146 -0
- package/src/undo.ts +451 -0
- package/src/utils/python.ts +211 -0
- package/src/utils/spawn.ts +56 -0
- package/src/voice.ts +83 -0
- package/src/voice_cli.ts +198 -0
- package/src/voices.ts +135 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
|
+
import { config, type McpServerConfig } from "./config.ts";
|
|
7
|
+
|
|
8
|
+
interface McpConnection {
|
|
9
|
+
client: Client;
|
|
10
|
+
name: string;
|
|
11
|
+
tools: Array<{ name: string; description: string; inputSchema: any }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const connections: McpConnection[] = [];
|
|
15
|
+
|
|
16
|
+
export async function connectMcpServers(): Promise<ToolDefinition[]> {
|
|
17
|
+
// 重连前先断开并清空旧连接:/new 重开会话(initAgent → connectMcpServers)会再次
|
|
18
|
+
// 执行本函数,若不清空模块级 connections,旧连接与子进程会重复累积并泄漏。
|
|
19
|
+
await disconnectAllMcp();
|
|
20
|
+
|
|
21
|
+
const allToolWrappers: ToolDefinition[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) {
|
|
24
|
+
try {
|
|
25
|
+
const conn = await connectServer(serverName, serverConfig);
|
|
26
|
+
connections.push(conn);
|
|
27
|
+
|
|
28
|
+
for (const tool of conn.tools) {
|
|
29
|
+
const wrapper = createToolWrapper(serverName, conn.client, tool);
|
|
30
|
+
allToolWrappers.push(wrapper);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log(`[MCP] Connected to "${serverName}" with ${conn.tools.length} tools`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.warn(`[MCP] Failed to connect to "${serverName}": ${err instanceof Error ? err.message : err}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return allToolWrappers;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function connectServer(name: string, serverConfig: McpServerConfig): Promise<McpConnection> {
|
|
43
|
+
const client = new Client(
|
|
44
|
+
{ name: "arona-agent", version: "1.0.0" },
|
|
45
|
+
{ capabilities: {} },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (serverConfig.command) {
|
|
49
|
+
const transport = new StdioClientTransport({
|
|
50
|
+
command: serverConfig.command,
|
|
51
|
+
args: serverConfig.args || [],
|
|
52
|
+
env: { ...process.env, ...serverConfig.env } as Record<string, string>,
|
|
53
|
+
});
|
|
54
|
+
await client.connect(transport);
|
|
55
|
+
} else if (serverConfig.url) {
|
|
56
|
+
// Streamable HTTP(HTTP/SSE)型 server。SDK 1.30.0 无顶层 headers 选项,
|
|
57
|
+
// 自定义 header 走 requestInit;不接 OAuth(authProvider),401 由调用方报错。
|
|
58
|
+
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
|
59
|
+
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
|
|
60
|
+
});
|
|
61
|
+
await client.connect(transport);
|
|
62
|
+
} else {
|
|
63
|
+
throw new Error(`Server "${name}" has no command or url configured`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const toolsResult = await client.listTools();
|
|
67
|
+
return {
|
|
68
|
+
client,
|
|
69
|
+
name,
|
|
70
|
+
tools: toolsResult.tools.map((t) => ({
|
|
71
|
+
name: t.name,
|
|
72
|
+
description: t.description || "",
|
|
73
|
+
inputSchema: t.inputSchema,
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type McpContentBlock =
|
|
79
|
+
| { type: "text"; text: string }
|
|
80
|
+
| { type: "image"; data: string; mimeType: string };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 把 MCP callTool 的返回内容统一转成 SDK tool result 的 content blocks:
|
|
84
|
+
* - text part 原样透传;
|
|
85
|
+
* - image part(MCP 结构 {type:"image", data, mimeType} 与 SDK 的 ImageContent 一致)直通为 image block;
|
|
86
|
+
* - 其他 part(resource/audio 等)与 structuredContent 用 JSON 兜底为文本,不静默丢弃。
|
|
87
|
+
*/
|
|
88
|
+
function collectMcpContent(result: any): McpContentBlock[] {
|
|
89
|
+
const blocks: McpContentBlock[] = [];
|
|
90
|
+
const textParts: string[] = [];
|
|
91
|
+
|
|
92
|
+
const flushText = () => {
|
|
93
|
+
if (textParts.length > 0) {
|
|
94
|
+
blocks.push({ type: "text", text: textParts.join("\n") });
|
|
95
|
+
textParts.length = 0;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (result.content && Array.isArray(result.content)) {
|
|
100
|
+
for (const part of result.content as any[]) {
|
|
101
|
+
if (part?.type === "text") {
|
|
102
|
+
textParts.push(String(part.text ?? ""));
|
|
103
|
+
} else if (part?.type === "image" && typeof part.data === "string") {
|
|
104
|
+
// 先 flush 已积累的文本,保持输出顺序
|
|
105
|
+
flushText();
|
|
106
|
+
blocks.push({ type: "image", data: part.data, mimeType: part.mimeType || "image/png" });
|
|
107
|
+
} else if (part) {
|
|
108
|
+
try {
|
|
109
|
+
textParts.push(`[part:${part.type || "unknown"}] ${JSON.stringify(part)}`);
|
|
110
|
+
} catch {
|
|
111
|
+
textParts.push(`[part:${part.type || "unknown"}] (unserializable)`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (result.structuredContent !== undefined) {
|
|
117
|
+
try {
|
|
118
|
+
textParts.push(`[structuredContent] ${JSON.stringify(result.structuredContent)}`);
|
|
119
|
+
} catch {
|
|
120
|
+
textParts.push("[structuredContent] (unserializable)");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
flushText();
|
|
124
|
+
if (blocks.length === 0) {
|
|
125
|
+
blocks.push({ type: "text", text: "(empty result)" });
|
|
126
|
+
}
|
|
127
|
+
return blocks;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createToolWrapper(
|
|
131
|
+
serverName: string,
|
|
132
|
+
client: Client,
|
|
133
|
+
tool: { name: string; description: string; inputSchema: any },
|
|
134
|
+
): ToolDefinition {
|
|
135
|
+
// Build TypeBox schema from the MCP tool's JSON schema
|
|
136
|
+
// For simplicity, use a permissive object schema that accepts any properties
|
|
137
|
+
const parameters = Type.Object(
|
|
138
|
+
{},
|
|
139
|
+
{ additionalProperties: true, description: tool.description },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return defineTool({
|
|
143
|
+
name: `mcp_${serverName}_${tool.name}`,
|
|
144
|
+
label: `${serverName}/${tool.name}`,
|
|
145
|
+
description: `[MCP:${serverName}] ${tool.description}`,
|
|
146
|
+
parameters,
|
|
147
|
+
execute: async (_toolCallId, params) => {
|
|
148
|
+
try {
|
|
149
|
+
const result = await client.callTool({
|
|
150
|
+
name: tool.name,
|
|
151
|
+
arguments: params as Record<string, unknown>,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
content: collectMcpContent(result),
|
|
156
|
+
details: { server: serverName, tool: tool.name },
|
|
157
|
+
};
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new Error(`MCP tool ${serverName}/${tool.name} failed: ${err instanceof Error ? err.message : err}`);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function listMcpServers(): Array<{ name: string; tools: string[] }> {
|
|
166
|
+
return connections.map((c) => ({
|
|
167
|
+
name: c.name,
|
|
168
|
+
tools: c.tools.map((t) => t.name),
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function callMcpTool(serverName: string, toolName: string, args: Record<string, any>): Promise<string> {
|
|
173
|
+
const conn = connections.find((c) => c.name === serverName);
|
|
174
|
+
if (!conn) throw new Error(`MCP server "${serverName}" not connected`);
|
|
175
|
+
if (!conn.tools.find((t) => t.name === toolName)) throw new Error(`Tool "${toolName}" not found on server "${serverName}"`);
|
|
176
|
+
|
|
177
|
+
const result = await conn.client.callTool({ name: toolName, arguments: args });
|
|
178
|
+
return collectMcpContent(result)
|
|
179
|
+
.map((b) => (b.type === "text" ? b.text : `[image: ${b.mimeType}]`))
|
|
180
|
+
.join("\n") || "(empty result)";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function disconnectAllMcp(): Promise<void> {
|
|
184
|
+
for (const conn of connections) {
|
|
185
|
+
try {
|
|
186
|
+
await conn.client.close();
|
|
187
|
+
} catch {
|
|
188
|
+
// Ignore
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
connections.length = 0;
|
|
192
|
+
}
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { MEMORY_FILE, SESSIONS_DIR } from "./config.ts";
|
|
4
|
+
import { t, getLang } from "./locale.ts";
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// MEMORY.md management
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MEMORY_CONTENT_ZH = `# ARONA Memory
|
|
11
|
+
|
|
12
|
+
阿洛娜的持久记忆。每次会话启动时自动加载到 system prompt,由 Arona 主动调用 \`save_memory\` 工具维护。
|
|
13
|
+
|
|
14
|
+
按三个 category 组织:
|
|
15
|
+
|
|
16
|
+
## 老师
|
|
17
|
+
|
|
18
|
+
关于老师的硬事实:时区、设备、常用项目、身份背景等。
|
|
19
|
+
写入示例:\`save_memory(content="时区 Asia/Shanghai", category="老师")\` → 产生 \`## [老师] _<ISO 时间戳>_ 时区 Asia/Shanghai\`
|
|
20
|
+
|
|
21
|
+
## 小习惯
|
|
22
|
+
|
|
23
|
+
工作/工具/风格偏好,例如"写 Rust 喜欢先看 lifetime"、"不喜欢 commit 时自动 push"等。
|
|
24
|
+
写入示例:\`save_memory(content="...", category="小习惯")\` → 产生 \`## [小习惯] _<时间戳>_ ...\`
|
|
25
|
+
|
|
26
|
+
## 我们之间
|
|
27
|
+
|
|
28
|
+
互动记忆、共同事件。
|
|
29
|
+
一般条目:\`save_memory(content="...", category="我们之间")\` → 产生 \`## [我们之间] _<时间戳>_ ...\`
|
|
30
|
+
|
|
31
|
+
**心情基线**(跨会话持久化的情绪状态)使用专门标题,便于 Arona 启动时自动加载到 system prompt。具体格式见 system prompt 中"心情基线规则"段的写入格式说明。
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const DEFAULT_MEMORY_CONTENT_EN = `# ARONA Memory
|
|
35
|
+
|
|
36
|
+
Arona's persistent memory. Loaded into the system prompt at every session start, maintained by Arona via the \`save_memory\` tool.
|
|
37
|
+
|
|
38
|
+
Organized by three categories:
|
|
39
|
+
|
|
40
|
+
## Teacher
|
|
41
|
+
|
|
42
|
+
Hard facts about Sensei: timezone, devices, usual projects, background, etc.
|
|
43
|
+
Example write: \`save_memory(content="timezone Asia/Shanghai", category="Teacher")\` → produces \`## [Teacher] _<ISO timestamp>_ timezone Asia/Shanghai\`
|
|
44
|
+
|
|
45
|
+
## Habits
|
|
46
|
+
|
|
47
|
+
Work/tool/style preferences, e.g. "likes to check lifetimes first when writing Rust", "dislikes auto-push on commit", etc.
|
|
48
|
+
Example write: \`save_memory(content="...", category="Habits")\` → produces \`## [Habits] _<timestamp>_ ...\`
|
|
49
|
+
|
|
50
|
+
## Us
|
|
51
|
+
|
|
52
|
+
Interaction memories, shared events.
|
|
53
|
+
General entry: \`save_memory(content="...", category="Us")\` → produces \`## [Us] _<timestamp>_ ...\`
|
|
54
|
+
|
|
55
|
+
**Mood baseline** (the emotional state persisted across sessions) uses a dedicated heading so Arona can auto-load it into the system prompt at startup. See the "Mood baseline rules" section of the system prompt for the write format.
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
/** 按当前语言返回默认 MEMORY.md 模板(首次写入时定型;存量文件不迁移)。 */
|
|
59
|
+
function getDefaultMemoryContent(): string {
|
|
60
|
+
return getLang() === "en" ? DEFAULT_MEMORY_CONTENT_EN : DEFAULT_MEMORY_CONTENT_ZH;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadMemory(): string {
|
|
64
|
+
if (!existsSync(MEMORY_FILE)) {
|
|
65
|
+
const content = getDefaultMemoryContent();
|
|
66
|
+
writeFileSync(MEMORY_FILE, content);
|
|
67
|
+
return content;
|
|
68
|
+
}
|
|
69
|
+
return readFileSync(MEMORY_FILE, "utf-8");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function appendToMemory(content: string, category?: string): void {
|
|
73
|
+
const timestamp = new Date().toISOString();
|
|
74
|
+
const header = category ? `## [${category}]` : `## Memory`;
|
|
75
|
+
const entry = `\n${header}\n_${timestamp}_\n${content}\n`;
|
|
76
|
+
const current = existsSync(MEMORY_FILE) ? readFileSync(MEMORY_FILE, "utf-8") : "";
|
|
77
|
+
writeFileSync(MEMORY_FILE, current + entry);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 从 MEMORY.md 内容中提取最近一条心情基线记录。
|
|
82
|
+
* 匹配中英双标题("## [我们之间] · 心情基线" / "## [Us] · Mood baseline")下、
|
|
83
|
+
* 跳过时间戳行后的正文块(到下一个 ## 标题或文档末尾)。无则返回空字符串。
|
|
84
|
+
*/
|
|
85
|
+
export function loadMoodBaseline(memoryContent: string): string {
|
|
86
|
+
if (!memoryContent) return "";
|
|
87
|
+
const re = /## \[(?:我们之间|Us)\] · (?:心情基线|Mood baseline)\n_[^_\n]+_\n([\s\S]*?)(?=\n## |\n*$)/;
|
|
88
|
+
const m = memoryContent.match(re);
|
|
89
|
+
return m ? m[1].trim() : "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================
|
|
93
|
+
// 运行时记忆增量(MEMORY.md 变更检测 → 注入下一轮 user 消息末尾)
|
|
94
|
+
// ============================================================
|
|
95
|
+
|
|
96
|
+
// 上次注入时的记忆内容基线。null 表示尚未建立基线。
|
|
97
|
+
let memoryBaseline: string | null = null;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 记录当前 MEMORY.md 内容为基线(不注入)。
|
|
101
|
+
* session 创建时(initAgent / initSubAgent)调用:system prompt 开头的初始记忆
|
|
102
|
+
* 快照已包含基线内容,避免首次 getMemoryDelta() 重复注入。
|
|
103
|
+
*/
|
|
104
|
+
export function snapshotMemory(): void {
|
|
105
|
+
memoryBaseline = existsSync(MEMORY_FILE) ? readFileSync(MEMORY_FILE, "utf-8") : "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 检测 MEMORY.md 自基线以来是否变化,返回应注入到下一轮 user 消息末尾的增量;无变化返回 null。
|
|
110
|
+
* - 纯追加(save_memory 走 appendToMemory 追加):返回新增后缀,token 开销最小。
|
|
111
|
+
* - 检测到非纯追加(手动编辑/重写):退化为返回最新全量内容,保证不丢信息。
|
|
112
|
+
* - 有变化时只注入一次,之后前缀稳定,缓存不重复失效。
|
|
113
|
+
*/
|
|
114
|
+
export function getMemoryDelta(): string | null {
|
|
115
|
+
const current = existsSync(MEMORY_FILE) ? readFileSync(MEMORY_FILE, "utf-8") : "";
|
|
116
|
+
if (memoryBaseline === null) {
|
|
117
|
+
// 未建立基线(理论上 snapshotMemory 已在 init 时调用):以当前内容为基线,不注入
|
|
118
|
+
memoryBaseline = current;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (current === memoryBaseline) return null;
|
|
122
|
+
|
|
123
|
+
// 找最长公共前缀(append-only 时 LCP 就是旧内容,差异 = 新增后缀)
|
|
124
|
+
let i = 0;
|
|
125
|
+
const maxLen = Math.min(memoryBaseline.length, current.length);
|
|
126
|
+
while (i < maxLen && memoryBaseline[i] === current[i]) i++;
|
|
127
|
+
|
|
128
|
+
const isPureAppend = i >= memoryBaseline.length && current.length > memoryBaseline.length;
|
|
129
|
+
const added = current.slice(i).trim();
|
|
130
|
+
memoryBaseline = current; // 无论注入与否都更新基线,避免同内容重复注入
|
|
131
|
+
|
|
132
|
+
if (!added) return null;
|
|
133
|
+
if (!isPureAppend) {
|
|
134
|
+
// 手动编辑/重写:LCP 截断不可靠,退化为全量最新内容
|
|
135
|
+
return `# 记忆更新(MEMORY.md 内容已变更)\n\n${current}`;
|
|
136
|
+
}
|
|
137
|
+
return `# 记忆更新(新增)\n\n${added}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================================
|
|
141
|
+
// Session management (conditional save)
|
|
142
|
+
// ============================================================
|
|
143
|
+
|
|
144
|
+
let hasConversation = false;
|
|
145
|
+
|
|
146
|
+
export function markConversation(): void {
|
|
147
|
+
hasConversation = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resetConversationFlag(): void {
|
|
151
|
+
hasConversation = false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getHasConversation(): boolean {
|
|
155
|
+
return hasConversation;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface SessionInfo {
|
|
159
|
+
filename: string;
|
|
160
|
+
path: string;
|
|
161
|
+
timestamp: string;
|
|
162
|
+
preview: string;
|
|
163
|
+
model: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface SessionHeader {
|
|
167
|
+
type: "arona-session";
|
|
168
|
+
version: number;
|
|
169
|
+
timestamp: string;
|
|
170
|
+
model: string;
|
|
171
|
+
preview: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 从首条 user 消息提取会话预览。
|
|
176
|
+
* 剥离 repl.ts 注入的桌宠手势场景块(全角括号 `(…)` 包裹、后随空行),
|
|
177
|
+
* 避免"摸头/dizzy 提示词"污染会话命名(用户首条消息常是手势触发)。
|
|
178
|
+
* 仅影响预览命名,不改动存储内容。
|
|
179
|
+
*/
|
|
180
|
+
function firstUserPreview(messages: any[]): string {
|
|
181
|
+
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
182
|
+
if (!firstUserMsg) return "(empty)";
|
|
183
|
+
const content =
|
|
184
|
+
typeof firstUserMsg.content === "string"
|
|
185
|
+
? firstUserMsg.content
|
|
186
|
+
: Array.isArray(firstUserMsg.content)
|
|
187
|
+
? firstUserMsg.content
|
|
188
|
+
.filter((c: any) => c.type === "text")
|
|
189
|
+
.map((c: any) => c.text)
|
|
190
|
+
.join(" ")
|
|
191
|
+
: "";
|
|
192
|
+
// 剥离开头的注入块:`(…)\n\n<用户真实输入>`
|
|
193
|
+
const cleaned = content.replace(/^([^)]*)[ \t]*\n*/u, "");
|
|
194
|
+
let preview = cleaned.slice(0, 50).replace(/\n/g, " ");
|
|
195
|
+
if (cleaned.length > 50) preview += "...";
|
|
196
|
+
return preview;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function saveSession(messages: any[], model: string): string | null {
|
|
200
|
+
if (!hasConversation) {
|
|
201
|
+
console.log(t("无对话可保存。", "No conversation to save."));
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Find first user message for preview (剥离桌宠手势注入块,见 firstUserPreview)
|
|
206
|
+
const preview = firstUserPreview(messages);
|
|
207
|
+
|
|
208
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
209
|
+
const safePreview = preview.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "_").slice(0, 30);
|
|
210
|
+
const filename = `${timestamp}__${safePreview}.jsonl`;
|
|
211
|
+
const filepath = join(SESSIONS_DIR, filename);
|
|
212
|
+
|
|
213
|
+
const header: SessionHeader = {
|
|
214
|
+
type: "arona-session",
|
|
215
|
+
version: 1,
|
|
216
|
+
timestamp: new Date().toISOString(),
|
|
217
|
+
model,
|
|
218
|
+
preview,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const lines: string[] = [JSON.stringify(header)];
|
|
222
|
+
for (const msg of messages) {
|
|
223
|
+
lines.push(JSON.stringify(msg));
|
|
224
|
+
}
|
|
225
|
+
writeFileSync(filepath, lines.join("\n"));
|
|
226
|
+
console.log(t(`会话已保存到 ${filepath}`, `Session saved to ${filepath}`));
|
|
227
|
+
return filepath;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 将会话保存到指定路径(覆盖原文件)。
|
|
232
|
+
* 用于 /resume 恢复的会话退出时保存回原文件,而非另存为新文件。
|
|
233
|
+
* 保留原文件的 header(timestamp/preview),仅更新 model 字段。
|
|
234
|
+
*/
|
|
235
|
+
export function saveSessionToPath(filepath: string, messages: any[], model: string): void {
|
|
236
|
+
let header: SessionHeader;
|
|
237
|
+
try {
|
|
238
|
+
const content = readFileSync(filepath, "utf-8");
|
|
239
|
+
header = JSON.parse(content.split("\n")[0]) as SessionHeader;
|
|
240
|
+
header.model = model; // 模型可能已切换,更新之
|
|
241
|
+
} catch {
|
|
242
|
+
// 原文件不存在或损坏,生成新 header(剥离桌宠手势注入块,见 firstUserPreview)
|
|
243
|
+
const preview = firstUserPreview(messages);
|
|
244
|
+
header = {
|
|
245
|
+
type: "arona-session",
|
|
246
|
+
version: 1,
|
|
247
|
+
timestamp: new Date().toISOString(),
|
|
248
|
+
model,
|
|
249
|
+
preview,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const lines: string[] = [JSON.stringify(header)];
|
|
254
|
+
for (const msg of messages) {
|
|
255
|
+
lines.push(JSON.stringify(msg));
|
|
256
|
+
}
|
|
257
|
+
writeFileSync(filepath, lines.join("\n"));
|
|
258
|
+
console.log(t(`会话已保存到 ${filepath}`, `Session saved to ${filepath}`));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function listSessions(): SessionInfo[] {
|
|
262
|
+
if (!existsSync(SESSIONS_DIR)) return [];
|
|
263
|
+
const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".jsonl"));
|
|
264
|
+
const sessions: SessionInfo[] = [];
|
|
265
|
+
|
|
266
|
+
for (const filename of files) {
|
|
267
|
+
const filepath = join(SESSIONS_DIR, filename);
|
|
268
|
+
try {
|
|
269
|
+
const content = readFileSync(filepath, "utf-8");
|
|
270
|
+
const firstLine = content.split("\n")[0];
|
|
271
|
+
const header = JSON.parse(firstLine) as SessionHeader;
|
|
272
|
+
if (header.type === "arona-session") {
|
|
273
|
+
sessions.push({
|
|
274
|
+
filename,
|
|
275
|
+
path: filepath,
|
|
276
|
+
timestamp: header.timestamp,
|
|
277
|
+
preview: header.preview,
|
|
278
|
+
model: header.model,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
// Skip malformed files
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Sort by timestamp descending (newest first)
|
|
287
|
+
sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
288
|
+
return sessions;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function loadSession(filepath: string): any[] {
|
|
292
|
+
const content = readFileSync(filepath, "utf-8");
|
|
293
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
294
|
+
const messages: any[] = [];
|
|
295
|
+
|
|
296
|
+
for (const line of lines) {
|
|
297
|
+
const parsed = JSON.parse(line);
|
|
298
|
+
if (parsed.type === "arona-session") continue; // Skip header
|
|
299
|
+
messages.push(parsed);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return messages;
|
|
303
|
+
}
|