feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s

硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
This commit is contained in:
2026-09-08 09:35:58 +08:00
parent 839860083f
commit 9b45c445bf
85 changed files with 5286 additions and 1158 deletions
@@ -285,9 +285,11 @@ describe.skipIf(!dbAvailable)('isUnconfiguredGlobalKey — 边界值矩阵', ()
expect(isUnconfiguredGlobalKey(key, value)).toBe(expected);
});
it('isGlobalKey 边界:完整前缀匹配而非子串(deepseek.contextWindow 是全局 key', () => {
expect(isGlobalKey('deepseek.contextWindow')).toBe(true);
expect(isGlobalKey('deepseek.apiKey')).toBe(true);
it('isGlobalKey 边界:完整前缀匹配而非子串(v0.8.1: provider 前缀已废除', () => {
// v0.8.1: deepseek/agnes/mimo/openai/anthropic/ollama 前缀从全局清单移除
//(其唯一键 contextWindow/numCtx 已废除,由全局 llm.contextWindow 取代)
expect(isGlobalKey('deepseek.contextWindow')).toBe(false);
expect(isGlobalKey('deepseek.apiKey')).toBe(false);
expect(isGlobalKey('memory.consolidationEnabled')).toBe(true);
expect(isGlobalKey('memory.custom')).toBe(true);
// 非全局前缀
@@ -497,9 +499,9 @@ describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 边界矩阵', () => {
});
it('数值边界:默认值 float 精确相等判定', () => {
// deepseek.contextWindow 默认 1000000
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 1000000 })).toBe(0);
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 64000 })).toBe(1);
// v0.8.1: deepseek.contextWindow 键已废除(迁移 12)——改用存续键 llm.maxTokens(默认 63488
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.maxTokens': 63488 })).toBe(0);
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.maxTokens': 64000 })).toBe(1);
});
it('迁移后新 ConfigService 读取(跨空间共享生效)', () => {
@@ -0,0 +1,113 @@
/**
* MCPManager getPrompt / readResource 测试(v0.8.1 P1-4
*
* prompts/resources 对话内可用化的主进程侧契约:
* 1. getPromptmessages 展平为文本(text 块拼接、多消息 \n\n 连接)、
* 未连接 server 显式抛错;
* 2. readResourcetext 内容返回、二进制(blob)显式拒绝、
* 空 contents 返回 null。
*
* 通过反射注入受控 server 状态(绕开真实 SDK 连接),与 IPC 层解耦。
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { MCPManager } from '../mcp-manager.service';
import type { ToolRegistry } from '../../harness/tools/registry';
function makeManager(): MCPManager {
const fakeDB = {
prepare: () => ({ run: () => ({ changes: 0 }) }),
} as unknown as ConstructorParameters<typeof MCPManager>[0] extends () => infer D ? D : never;
const registry = {
registerMCP: vi.fn(() => true),
unregisterMCPTools: vi.fn(),
} as unknown as ToolRegistry;
return new MCPManager(() => fakeDB, registry);
}
/** 反射注入受控 server 状态(mock client */
function injectServer(manager: MCPManager, client: Record<string, ReturnType<typeof vi.fn>>): void {
const states = (
manager as unknown as {
servers: Map<
string,
{
config: { name: string };
status: string;
client: unknown;
tools: unknown[];
resources: unknown[];
prompts: unknown[];
}
>;
}
).servers;
states.set('test-server', {
config: { name: 'test-server' } as never,
status: 'connected',
client: client as never,
tools: [],
resources: [],
prompts: [],
});
}
describe('MCPManager — getPromptv0.8.1 P1-4', () => {
it('messages 展平为文本,多消息以空行连接', async () => {
const manager = makeManager();
injectServer(manager, {
getPrompt: vi.fn().mockResolvedValue({
description: 'demo prompt',
messages: [
{ role: 'user', content: { type: 'text', text: 'Line A' } },
{ role: 'user', content: { type: 'text', text: 'Line B' } },
],
}),
});
const result = await manager.getPrompt('test-server', 'demo');
expect(result).not.toBeNull();
expect(result!.text).toBe('Line A\n\nLine B');
expect(result!.description).toBe('demo prompt');
});
it('server 未连接 → 显式抛错(调用方以 error 返回)', async () => {
const manager = makeManager();
await expect(manager.getPrompt('nope', 'demo')).rejects.toThrow(/not connected/);
});
});
describe('MCPManager — readResourcev0.8.1 P1-4', () => {
it('text 内容正常返回', async () => {
const manager = makeManager();
injectServer(manager, {
readResource: vi.fn().mockResolvedValue({
contents: [{ uri: 'file:///a.txt', text: 'hello resource', mimeType: 'text/plain' }],
}),
});
const result = await manager.readResource('test-server', 'file:///a.txt');
expect(result).toEqual({ text: 'hello resource', mimeType: 'text/plain' });
});
it('二进制(blob)内容显式拒绝', async () => {
const manager = makeManager();
injectServer(manager, {
readResource: vi.fn().mockResolvedValue({
contents: [{ uri: 'file:///a.png', blob: 'aGVsbG8=' }],
}),
});
await expect(manager.readResource('test-server', 'file:///a.png')).rejects.toThrow(/binary/);
});
it('空 contents → null', async () => {
const manager = makeManager();
injectServer(manager, {
readResource: vi.fn().mockResolvedValue({ contents: [] }),
});
expect(await manager.readResource('test-server', 'file:///none')).toBeNull();
});
});
@@ -0,0 +1,114 @@
/**
* SessionService 游标分页测试(v0.8.1 P2-3
*
* 契约:无 limit 全量;limit 无游标 = 尾部窗口(最后 N 条,升序返回);
* limit + beforeRowid = 游标之前的更早 N 条(升序返回);完整性判定
* (返回条数 < limit 即到会话开头)由调用方依据长度比较。
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
let dbAvailable = true;
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
const probe = new Database(':memory:');
probe.close();
} catch {
dbAvailable = false;
}
import { SessionService } from '../session.service';
describe.skipIf(!dbAvailable)('SessionService — 游标分页(v0.8.1 P2-3', () => {
let db: InstanceType<typeof Database>;
let svc: SessionService;
const sessionId = 's_page';
beforeAll(() => {
if (!dbAvailable) return;
db = new Database(':memory:');
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at INTEGER,
updated_at INTEGER,
message_count INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT,
content TEXT,
reasoning_content TEXT,
tool_calls TEXT,
tool_result TEXT,
attachments TEXT,
iteration INTEGER,
created_at INTEGER
);
CREATE TABLE session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT,
summarized_until_rowid INTEGER,
updated_at INTEGER
);
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
session_id TEXT,
title TEXT,
description TEXT,
status TEXT,
priority TEXT,
parent_id TEXT,
assigned_to TEXT,
order_idx INTEGER,
created_at INTEGER,
updated_at INTEGER,
completed_at INTEGER
);
`);
db.prepare('INSERT INTO sessions (id) VALUES (?)').run(sessionId);
svc = new SessionService(() => db);
for (let i = 1; i <= 10; i++) {
svc.saveMessage({ sessionId, role: 'user', content: `msg-${i}` });
}
});
afterAll(() => {
if (db) db.close();
});
it('无 limit → 全量(向后兼容)', () => {
expect(svc.getMessages(sessionId)).toHaveLength(10);
});
it('limit 无游标 → 尾部窗口(最后 N 条,升序返回)', () => {
const tail = svc.getMessages(sessionId, { limit: 3 });
expect(tail.map((m) => m.content)).toEqual(['msg-8', 'msg-9', 'msg-10']);
});
it('limit + beforeRowid → 游标之前 N 条(升序返回)', () => {
const full = svc.getMessages(sessionId);
const cursorRowId = full[6].rowId!; // msg-7 的 rowid
const older = svc.getMessages(sessionId, { limit: 3, beforeRowid: cursorRowId });
expect(older.map((m) => m.content)).toEqual(['msg-4', 'msg-5', 'msg-6']);
});
it('游标翻到开头:返回条数 < limit 表示已完整', () => {
const full = svc.getMessages(sessionId);
const first = full[0].rowId!;
const older = svc.getMessages(sessionId, { limit: 3, beforeRowid: first });
expect(older).toHaveLength(0);
});
});
+106 -12
View File
@@ -21,6 +21,19 @@ function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* v0.8.1: 已废除的配置键(单一来源 —— 迁移 12 与全局配置层清理共用)。
* 分 Provider contextWindow 与 ollama.numCtx 由全局 llm.contextWindow 取代。
*/
export const DEPRECATED_CONFIG_KEYS = [
'ollama.numCtx',
'deepseek.contextWindow',
'agnes.contextWindow',
'mimo.contextWindow',
'openai.contextWindow',
'anthropic.contextWindow',
] as const;
/** 配置默认值条目(P1-13: 单一来源,global-config.service.ts 的 SEED_DEFAULTS 由此派生) */
export interface ConfigDefaultEntry {
key: string;
@@ -42,7 +55,13 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'llm.baseURL', value: '', category: 'llm' },
// F-8 接通: temperature/maxTokens 此前为死配置(引擎硬编码),现由 main.ts 注入引擎
{ key: 'llm.temperature', value: 0, category: 'llm' },
// v0.8.0 FEAT-1 / v0.8.1: 最大输出上限 —— 唯一合法的输出上限配置(全局,跨 Provider)
{ key: 'llm.maxTokens', value: 63488, category: 'llm' },
// v0.8.1: 上下文长度 —— 唯一合法的上下文窗口配置(全局,跨 Provider/模型;
// 驱动引擎压缩预算、前端占用指示,Ollama 场景同时作为 num_ctx 下发)。
// 分 Provider 的 deepseek/agnes/mimo/openai/anthropic.contextWindow 与 ollama.numCtx
// 已废除(迁移 12 清理遗留键)。
{ key: 'llm.contextWindow', value: 131072, category: 'llm' },
// v0.5.4: 多模态总开关 — 即使模型支持多模态,未开启也不能上传图片(默认关闭,
// 用户在设置/引导向导显式开启;上传入口 = 开关 × 模型能力双重判断)
{ key: 'llm.multimodalEnabled', value: false, category: 'llm' },
@@ -74,6 +93,9 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'memory.consolidationEnabled', value: true, category: 'memory' },
{ key: 'memory.consolidationMinChars', value: 200, category: 'memory' },
{ key: 'memory.consolidationIntervalMs', value: 600000, category: 'memory' },
// v0.8.1 P1-1: 本地向量记忆嵌入模型(Ollama embedding 模型名,空 = 向量检索关闭,
// 仅 TF-IDF)。唯一合法来源是设置面板 —— 代码中不存在默认模型名。
{ key: 'memory.embeddingModel', value: '', category: 'memory' },
// v0.7.3 P4-2: MCP 自动重连开关(mcp-manager.service 消费;断连后指数退避重试)
{ key: 'mcp.autoReconnect', value: true, category: 'mcp' },
@@ -88,16 +110,8 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'logging.auditEnabled', value: true, category: 'logging' },
{ key: 'logging.traceEnabled', value: true, category: 'logging' },
// Ollama 配置
{ key: 'ollama.numCtx', value: null, category: 'ollama' },
// Provider 上下文窗口配置(用于 Engine 压缩判断和 UI 显示)
{ key: 'deepseek.contextWindow', value: 1000000, category: 'deepseek' },
{ key: 'agnes.contextWindow', value: 1000000, category: 'agnes' },
{ key: 'mimo.contextWindow', value: 1000000, category: 'mimo' },
// P3: OpenAI / Anthropic Provider
{ key: 'openai.contextWindow', value: 128000, category: 'openai' },
{ key: 'anthropic.contextWindow', value: 200000, category: 'anthropic' },
// v0.8.1: Ollama numCtx 独立配置已废除(与「上下文长度」合并为 llm.contextWindow);
// Provider 上下文窗口分键配置已废除 —— 遗留键由迁移 12 从存量库清理。
// Onboarding
{ key: 'onboarding.completed', value: false, category: 'general' },
@@ -114,8 +128,12 @@ export class DatabaseService {
* v0.7.4 P4-4: 1 → 2 —— 迁移 9messages_fts trigram)纳入版本化,
* 失败中断批次不盖章 → 下次启动重试(根治"失败被吞 + 永久跳过")。
* v0.8.0 P2-1: 2 → 3 —— 迁移 10sessions.deleted_at 回收站软删除列)。
* v0.8.1: 3 → 4 —— 迁移 11(记忆表 embedding 列,本地向量混合检索)+
* 迁移 12(清理已废除的 llm.contextWindow 分 Provider 键与 ollama.numCtx)。
* v0.8.1 review: 4 → 5 —— 迁移 13working_memories 补 sessions 外键 CASCADE
* 孤儿行清理;根治历史 schema 缺失级联导致的孤儿数据)。
*/
static readonly SCHEMA_VERSION = 3;
static readonly SCHEMA_VERSION = 5;
constructor(workspacePath?: string) {
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
@@ -280,6 +298,7 @@ export class DatabaseService {
);
-- ===== 工作记忆表 =====
-- v0.8.1 review: 补 sessions 外键(CASCADE)—— 历史建表缺失级联
CREATE TABLE IF NOT EXISTS working_memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
@@ -287,7 +306,9 @@ export class DatabaseService {
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
UNIQUE(session_id, task_id, key)
tf_cache TEXT,
UNIQUE(session_id, task_id, key),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
-- ===== v0.2.0: 任务表 =====
@@ -657,6 +678,79 @@ export class DatabaseService {
}
}
// v0.8.1 P1-1 迁移 11: 记忆表 embedding 列(本地向量混合检索)。
// 存量记忆行为 NULL = 未向量化(检索时回退 TF-IDF 路径),新写入由
// MemoryEmbedder 异步回填;向量维度由用户配置的 embedding 模型决定,
// 故用 BLOB 存 Float32Array 而非固定宽度 F32Blob 列。
tryAddColumn('episodic_memories', 'embedding', 'BLOB');
tryAddColumn('semantic_memories', 'embedding', 'BLOB');
// v0.8.1 迁移 12: 清理已废除的上下文窗口配置键(全局 llm.contextWindow 取代)。
// 旧键残留会使设置面板与引擎出现双源语义,启动迁移一次性移除(幂等)。
{
const placeholders = DEPRECATED_CONFIG_KEYS.map(() => '?').join(', ');
const result = db
.prepare(`DELETE FROM app_config WHERE key IN (${placeholders})`)
.run(...DEPRECATED_CONFIG_KEYS);
if (result.changes > 0) {
log.info(
`[DB] Migration: removed ${result.changes} deprecated context-window config key(s) (replaced by llm.contextWindow)`,
);
}
}
// v0.8.1 review 迁移 13: working_memories 补 sessions 外键(ON DELETE CASCADE
// 历史缺陷:该表自 v0.2.0 建表起就没有 sessions 外键 —— 会话删除/清空后
// working 行成为孤儿(行为面已由 0.8.1 终态清理接线覆盖,此处根治 schema)。
// 先清理孤儿行(早于 0.8.1 的历史删除遗留),再以 FK 重建。
{
const hasWorking = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='working_memories'")
.get();
// hasSessions 由迁移 10 块内查询(块作用域),此处独立复查
const hasSessions = !!db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
.get();
const fkRows = hasWorking
? (db.prepare('PRAGMA foreign_key_list(working_memories)').all() as Array<{
table: string;
}>)
: [];
const hasSessionsFk = fkRows.some((fk) => fk.table === 'sessions');
if (hasWorking && hasSessions && !hasSessionsFk) {
log.info('[DB] Migration: rebuilding working_memories with sessions FK (CASCADE)');
const rebuildWorking = db.transaction(() => {
db.exec(`
DELETE FROM working_memories
WHERE session_id NOT IN (SELECT id FROM sessions);
CREATE TABLE working_memories_new (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
tf_cache TEXT,
UNIQUE(session_id, task_id, key),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
INSERT INTO working_memories_new (id, session_id, task_id, key, value, updated_at, tf_cache)
SELECT id, session_id, task_id, key, value, updated_at, tf_cache
FROM working_memories;
DROP TABLE working_memories;
ALTER TABLE working_memories_new RENAME TO working_memories;
CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id);
`);
});
rebuildWorking();
log.info('[DB] Migration: working_memories rebuilt with sessions FK');
}
}
// v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer
// 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild
// 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的
+17 -8
View File
@@ -11,8 +11,10 @@
* 工作空间 DB 仍保留会话/消息/记忆/审计等"工作空间级数据"。
*
* 全局 key 规则:
* - llm.* / agent.* / ollama.* / {provider}.contextWindow / security.* / ui.* / logging.*
* - llm.* / agent.* / security.* / ui.* / logging.* / memory.*
* - onboarding.completed
* v0.8.1: 分 Provider contextWindow 键与 ollama.numCtx 废除 —— 相应前缀从
* 全局判定清单移除,存量废键由 initialize 清理,与工作空间 DB 迁移 12 对齐)
* 写入时同时写工作空间 DB(兼容旧逻辑)和全局 JSON;
* 读取时优先工作空间 DB,miss 时回退到全局 JSON。
*
@@ -23,7 +25,7 @@ import { app } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import log from 'electron-log';
import { CONFIG_DEFAULTS } from './database.service';
import { CONFIG_DEFAULTS, DEPRECATED_CONFIG_KEYS } from './database.service';
import {
decryptConfigValue,
encryptConfigValue,
@@ -37,15 +39,9 @@ const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
const GLOBAL_KEY_PREFIXES = [
'llm.',
'agent.',
'ollama.',
'security.',
'ui.',
'logging.',
'deepseek.',
'agnes.',
'mimo.',
'openai.',
'anthropic.',
'onboarding.',
// v0.7.3 P1-5: 记忆固化节流(机器级策略,跨工作空间一致)
'memory.',
@@ -110,6 +106,19 @@ export class GlobalConfigService {
if (existsSync(GLOBAL_CONFIG_FILE)) {
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
this.data = JSON.parse(raw) as GlobalConfigData;
// v0.8.1 review: 清除已废除的配置键(分 Provider contextWindow / ollama.numCtx),
// 与工作空间 DB 迁移 12 对齐 —— 全局层残留会使双源语义复活
let purged = 0;
for (const key of DEPRECATED_CONFIG_KEYS) {
if (key in this.data) {
delete this.data[key];
purged++;
}
}
if (purged > 0) {
this.flush();
log.info(`[GlobalConfig] Purged ${purged} deprecated config key(s)`);
}
log.info(
`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`,
);
+53
View File
@@ -871,6 +871,59 @@ export class MCPManager {
return { resources: state.resources ?? [], prompts: state.prompts ?? [] };
}
/**
* v0.8.1 P1-4: 获取 MCP Prompt 渲染结果(prompts/get)—— 供 ChatInput 斜杠
* 菜单填充输入框。messages 展平为文本(text 块拼接;role 前缀保留多消息语义)。
*/
async getPrompt(
serverName: string,
promptName: string,
args?: Record<string, string>,
): Promise<{ text: string; description?: string } | null> {
const state = this.servers.get(serverName);
if (!state || state.status !== 'connected' || !state.client) {
throw new Error(`MCP server '${serverName}' is not connected`);
}
const res = await state.client.getPrompt({
name: promptName,
arguments: args,
});
const parts: string[] = [];
for (const m of res.messages ?? []) {
// MCP PromptMessage.content 是单个 ContentBlocktext/image/audio/resource_link
// 联合)—— 仅提取文本块;经 unknown 中转以匹配 SDK 联合类型
const content = m.content as unknown as { type?: string; text?: string } | undefined;
const text = content?.type === 'text' ? (content.text ?? '') : '';
parts.push(text);
}
return { text: parts.filter((t) => t.length > 0).join('\n\n'), description: res.description };
}
/**
* v0.8.1 P1-4: 读取 MCP Resource 内容(resources/read)—— 供 @mcp 提及注入。
* 文本类内容展平返回;二进制(blob)内容拒绝(与附件管线"二进制拒绝"口径一致)。
*/
async readResource(
serverName: string,
uri: string,
): Promise<{ text: string; mimeType?: string } | null> {
const state = this.servers.get(serverName);
if (!state || state.status !== 'connected' || !state.client) {
throw new Error(`MCP server '${serverName}' is not connected`);
}
const res = await state.client.readResource({ uri });
const contents = res.contents ?? [];
const first = contents[0];
if (!first) return null;
if ('blob' in first && typeof first.blob === 'string') {
throw new Error(`Resource '${uri}' is binary content — inline injection is not supported`);
}
return {
text: ('text' in first ? first.text : '') ?? '',
mimeType: first.mimeType,
};
}
/**
* 关闭所有连接
*/
+30 -2
View File
@@ -283,12 +283,40 @@ export class SessionService {
* P2-11: 新增 afterRowid 参数——仅返回 rowid 大于该值的消息(分层上下文加载游标);
* 排序改用 rowid(插入序号),与截断/摘要游标语义一致
*/
/**
* 分页语义(v0.8.1 P2-3 游标分页):
* - 无 limit:全量(向后兼容 —— 导出/右键菜单等仍取完整历史);
* - limit + 无 beforeRowid:取**最后 N 条**(尾部窗口,会话打开首屏);
* - limit + beforeRowid:取该游标之前(更早)的 N 条(向上翻页)。
* 返回统一按 rowid 升序排列。
*/
getMessages(
sessionId: string,
options: { limit?: number; offset?: number; afterRowid?: number } = {},
options: { limit?: number; offset?: number; afterRowid?: number; beforeRowid?: number } = {},
): MessageInfo[] {
const db = this.getDBFn();
const { limit = 0, offset = 0, afterRowid = 0 } = options;
const { limit = 0, offset = 0, afterRowid = 0, beforeRowid = 0 } = options;
// 游标向上翻页:rowid < beforeRowidDESC 取 N 条后反转为升序
if (limit > 0 && beforeRowid > 0) {
const rows = db
.prepare(
`SELECT rowid AS row_id, * FROM messages WHERE session_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?`,
)
.all(sessionId, beforeRowid, limit) as MessageRow[];
return rows.reverse().map((row) => this.toMessageInfo(row));
}
// 尾部窗口:无游标且无 offset 时指定 limit → 最后 N 条(DESC 取后反转为升序)。
// offset>0 保留旧版 ASC limit/offset 分页语义(向后兼容既有调用方)。
if (limit > 0 && afterRowid === 0 && offset === 0) {
const rows = db
.prepare(
`SELECT rowid AS row_id, * FROM messages WHERE session_id = ? ORDER BY rowid DESC LIMIT ? OFFSET ?`,
)
.all(sessionId, limit, offset) as MessageRow[];
return rows.reverse().map((row) => this.toMessageInfo(row));
}
let sql = 'SELECT rowid AS row_id, * FROM messages WHERE session_id = ?';
const params: unknown[] = [sessionId];
+39 -1
View File
@@ -13,7 +13,16 @@
*/
import { join, resolve, sep } from 'path';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'fs';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
statSync,
renameSync,
unlinkSync,
} from 'fs';
import { app } from 'electron';
import log from 'electron-log';
// v0.8.0 P2-4: @ 提及文本片段的编码检测(与 read_file 同源)
@@ -306,6 +315,35 @@ export class WorkspaceService {
log.info(`MEMORY.md: appended to section "${section}"`);
}
/**
* v0.8.1 P1-2: 整体改写 MEMORY.md(记忆整理闭环的唯一写入口)。
*
* 供 MemoryMaintainer 应用"删除/合并"动作后重写文件 —— 工具层(file-guard
* 对工作空间根目录 MEMORY.md 的写保护不受影响:本方法只由主进程维护链路调用,
* 不经过 Agent 工具执行管道,Agent 仍无法绕过保护直接改写根目录记忆。
* 原子性:tmp + rename,与 write_file 工具同口径;写后同步 files.memory 缓存
* 并更新时间戳。
*/
rewriteMemory(content: string): void {
const memoryPath = join(this.workspacePath, 'MEMORY.md');
// 时间戳随写随更
const stamped = content.replace(/> 最后更新: .*/, `> 最后更新: ${new Date().toISOString()}`);
const tmpPath = `${memoryPath}.tmp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
writeFileSync(tmpPath, stamped, 'utf-8');
try {
renameSync(tmpPath, memoryPath);
} catch (err) {
try {
unlinkSync(tmpPath);
} catch {
/* 忽略清理失败 */
}
throw err;
}
this.files.memory = stamped;
log.info('MEMORY.md rewritten (memory maintenance)');
}
/**
* 校验 MEMORY.md 格式
*