修复: - main.ts 退出释放模型显存改用 getSetting(serverUrl),不再硬编码 127.0.0.1:11434(避免非默认地址时释放请求打到错误端口) - 备份导出/导入并入 localStorage 持久化状态(会话摘要、度量历史、轨迹降级缓存、主题),版本升级到 v2,实现完整备份 - 工具数量改为 getEnabledToolDefinitions().length 动态计算,删除写死"32 个"的硬编码 - 记忆日志区分操作来源:memory:write 透传 reason,标注"新增记忆/替换/删除/清空/TTL 衰减清理/访问统计写回(无新条目)",避免"写了但看不到新记忆"的困惑 可维护性: - 上下文压力逻辑收敛到统一 calculateContextStats,删除 getContextPressureLevel / getTrendAwareCompressThreshold 的重复实现 - 消除 validateToolArgs 同名碰撞(agent-engine 本地版改名 validateToolArgsQuick) - 子代理工具集改用 getEnabledToolDefinitions() 基线,跟随全局启用开关与 Plan 模式 - 抽取 html-utils.ts 纯函数模块(实体解码/HTML→文本/HTML→Markdown/拦截页检测/相关性评分),tool-handlers-system 净减约 190 行重复代码 - 统一静态导入(savePlanTracker/setPlanModeActive/collectDiagnostics/addWrittenFile) - console.* 使用处补充豁免说明(启动/退出/刷盘阶段无渲染进程可推送日志) - run_command 工具描述改为反映可配置执行模式 测试: - 新增 7 个测试文件 + 扩展 2 个,共 273 个测试(原 34 → 273) - 覆盖 agent-engine / agent-safety / context-manager / tool-registry / result-formatter / tool-parsing / memory-service / crypto / build-context / html-utils / utils / tool-handlers-fs - 全部通过 npm run typecheck && npm test && npm run build
This commit is contained in:
@@ -13,6 +13,10 @@ import { showConfirm } from './prompt-modal.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import type { ChatSession } from '../types.js';
|
||||
// A2: 备份携带 localStorage 持久化状态(会话摘要/度量历史/轨迹降级缓存)
|
||||
import { getSessionSummariesBackup, restoreSessionSummariesBackup } from '../services/context-manager.js';
|
||||
import { getMetricsBackup, restoreMetricsBackup } from '../services/agent-metrics.js';
|
||||
import { getTraceFallbackBackup, restoreTraceFallbackBackup } from '../services/agent-engine.js';
|
||||
|
||||
let settingsModalEl: HTMLElement;
|
||||
|
||||
@@ -443,12 +447,18 @@ async function exportAllSessions(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// A2: 携带 localStorage 持久化状态与主题,实现完整备份
|
||||
const backup = {
|
||||
app: 'Metona Ollama Client',
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: sessions.length,
|
||||
sessions
|
||||
sessions,
|
||||
// ── localStorage 持久化状态(不随 SQLite 迁移)──
|
||||
sessionSummaries: getSessionSummariesBackup(),
|
||||
metricsHistory: getMetricsBackup(),
|
||||
traceFallback: getTraceFallbackBackup(),
|
||||
theme: localStorage.getItem('metona-theme') || 'auto',
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -560,6 +570,17 @@ async function importSessions(filePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
const importResult = await db.importSessions(sessions);
|
||||
|
||||
// A2: 恢复 localStorage 持久化状态与主题(兼容 v1 旧备份,缺失时静默跳过)
|
||||
const dataObj = (data as Record<string, unknown>) || {};
|
||||
if (Array.isArray(dataObj.sessionSummaries)) restoreSessionSummariesBackup(dataObj.sessionSummaries as Parameters<typeof restoreSessionSummariesBackup>[0]);
|
||||
if (Array.isArray(dataObj.metricsHistory)) restoreMetricsBackup(dataObj.metricsHistory as Parameters<typeof restoreMetricsBackup>[0]);
|
||||
if (Array.isArray(dataObj.traceFallback)) restoreTraceFallbackBackup(dataObj.traceFallback as Parameters<typeof restoreTraceFallbackBackup>[0]);
|
||||
if (typeof dataObj.theme === 'string' && dataObj.theme) {
|
||||
localStorage.setItem('metona-theme', dataObj.theme);
|
||||
document.documentElement.setAttribute('data-theme', dataObj.theme === 'dark' ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
showToast(`导入完成:${importResult.imported} 个会话${importResult.skipped > 0 ? `,跳过 ${importResult.skipped} 个` : ''}`, 'success', 4000);
|
||||
logSuccess(`导入完成: ${importResult.imported} 个, 跳过 ${importResult.skipped} 个`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { logInfo, logError, logDebug } from '../services/log-service.js';
|
||||
import { escapeHtml, formatSize } from '../utils/utils.js';
|
||||
import { addToolResultHighlighting } from './chat-area.js';
|
||||
import { getEnabledToolDefinitions } from '../services/tool-registry.js';
|
||||
|
||||
// ── 工具卡片类型 ──
|
||||
interface ToolCallRecord {
|
||||
@@ -972,7 +973,7 @@ function renderToolCalls(): void {
|
||||
</div>
|
||||
<div class="ws-idle-divider"></div>
|
||||
<div class="ws-idle-desc">AI 对话中自动调用工具,结果在此展示</div>
|
||||
<div class="ws-idle-hint">共 32 个内置工具 · MCP 动态扩展</div>
|
||||
<div class="ws-idle-hint">共 ${getEnabledToolDefinitions().length} 个内置工具 · MCP 动态扩展</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v0.17.0</span>
|
||||
<span class="app-version">v0.17.1</span>
|
||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
initPlanTracker,
|
||||
getPlanTracker,
|
||||
clearPlanTracker,
|
||||
savePlanTracker,
|
||||
setPlanModeActive,
|
||||
setSubAgentConfirmHandler,
|
||||
} from './tool-registry.js';
|
||||
import {
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
// 错误恢复建议
|
||||
getErrorRecoverySuggestions,
|
||||
formatErrorRecovery,
|
||||
// 诊断报告
|
||||
collectDiagnostics,
|
||||
formatDiagnosticsReport,
|
||||
} from './agent-safety.js';
|
||||
import { search, formatMemoryContext } from './memory-service.js';
|
||||
import { formatToolResultForModel, summarizeAuditResult } from './result-formatter.js';
|
||||
@@ -64,7 +69,7 @@ import {
|
||||
// R125: Agent 状态检查点
|
||||
createCheckpoint, clearCheckpoints,
|
||||
} from './context-manager.js';
|
||||
import { executeHooks } from './hooks.js';
|
||||
import { executeHooks, addWrittenFile } from './hooks.js';
|
||||
import { recordIteration, recordToolCall, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
|
||||
import { getEffectiveNumCtx } from '../components/model-bar.js';
|
||||
import type {
|
||||
@@ -93,7 +98,7 @@ let _filteredTools: import('../types.js').ToolDefinition[] = [];
|
||||
/** S1/S6: 清洗不可信文本,移除提示词注入模式
|
||||
* P3 #10 增强:先标准化 Unicode/零宽字符,再匹配更多变体
|
||||
*/
|
||||
function sanitizeUntrustedInput(text: string): string {
|
||||
export function sanitizeUntrustedInput(text: string): string {
|
||||
if (!text) return '';
|
||||
// 先标准化:移除零宽字符、全角→半角,避免同形字符绕过
|
||||
let normalized = text
|
||||
@@ -425,7 +430,7 @@ function extractAffectedPath(call: ToolCall): string | null {
|
||||
}
|
||||
|
||||
/** 两个路径是否冲突(相同、父子、重叠) */
|
||||
function pathsConflict(a: string, b: string): boolean {
|
||||
export function pathsConflict(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
const na = a.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const nb = b.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
@@ -433,8 +438,9 @@ function pathsConflict(a: string, b: string): boolean {
|
||||
return na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/');
|
||||
}
|
||||
|
||||
/** P2-4: 工具参数前置校验 — 轻量级参数检查,避免无效参数浪费一轮迭代 */
|
||||
function validateToolArgs(toolName: string, args: Record<string, unknown>): string | null {
|
||||
/** P2-4: 工具参数前置校验 — 轻量级参数检查,避免无效参数浪费一轮迭代
|
||||
* 注:与 tool-registry 的 schema 校验 validateToolArgs 同名不同返回类型,此处重命名避免混淆 */
|
||||
export function validateToolArgsQuick(toolName: string, args: Record<string, unknown>): string | null {
|
||||
const getString = (key: string): string | null => {
|
||||
const v = args[key];
|
||||
if (typeof v === 'string' && v.trim().length > 0) return v;
|
||||
@@ -649,6 +655,29 @@ function _fallbackSaveTraces(traces: Array<Record<string, any>>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A2: 备份导出 — 读取轨迹降级缓存(供 .metona 备份携带) */
|
||||
export function getTraceFallbackBackup(): Array<Record<string, any>> {
|
||||
try {
|
||||
const raw = localStorage.getItem(TRACE_FALLBACK_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as Array<Record<string, any>>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A2: 备份导入 — 恢复轨迹降级缓存 */
|
||||
export function restoreTraceFallbackBackup(traces: Array<Record<string, any>>): void {
|
||||
if (!Array.isArray(traces)) return;
|
||||
try {
|
||||
const sliced = traces.slice(-TRACE_FALLBACK_MAX);
|
||||
localStorage.setItem(TRACE_FALLBACK_KEY, JSON.stringify(sliced));
|
||||
logInfo(`R9: 从备份恢复 ${sliced.length} 条轨迹降级缓存`);
|
||||
} catch (err) {
|
||||
logWarn(`R9: 恢复轨迹降级缓存失败: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存执行轨迹到 SQLite(缓冲写入) */
|
||||
async function saveTrace(trace: Record<string, any>): Promise<void> {
|
||||
_traceBuffer.push(trace);
|
||||
@@ -905,7 +934,6 @@ ${formatted}
|
||||
const resumeData = state.get<{ steps: Array<{index:number;label:string;done:boolean}>; total: number; done: number; loopCount: number } | null>('_planResumeData', null);
|
||||
if (resumeData && resumeData.steps.length > 0 && resumeData.done < resumeData.total) {
|
||||
// 直接从恢复数据构建追踪器,一次保存(避免 initPlanTracker 的冗余中间保存)
|
||||
const { savePlanTracker } = await import('./tool-registry.js');
|
||||
const tracker = {
|
||||
steps: resumeData.steps.map(s => ({ index: s.index, label: s.label, done: s.done })),
|
||||
total: resumeData.total,
|
||||
@@ -1019,7 +1047,7 @@ ${formatted}
|
||||
}
|
||||
|
||||
/** 按 token 预算截断文本(约 1.5 中文字/token, 4 英文字符/token) */
|
||||
function truncateByTokenBudget(text: string, maxTokens: number): string {
|
||||
export function truncateByTokenBudget(text: string, maxTokens: number): string {
|
||||
const estimated = estimateTokens(text);
|
||||
if (estimated <= maxTokens) return text;
|
||||
// 按比例截取
|
||||
@@ -1029,7 +1057,7 @@ function truncateByTokenBudget(text: string, maxTokens: number): string {
|
||||
}
|
||||
|
||||
/** 从 Plan Mode 输出中提取步骤列表(兼容多种模型格式) */
|
||||
function extractPlanSteps(content: string): string[] {
|
||||
export function extractPlanSteps(content: string): string[] {
|
||||
const steps: string[] = [];
|
||||
|
||||
// 策略1: 精确匹配 "## 执行计划" 区块中的编号行
|
||||
@@ -1485,7 +1513,7 @@ async function handleExecuting(
|
||||
}
|
||||
|
||||
// P2-4: 工具参数前置校验 — 参数无效直接返回错误,不执行实际工具,节省一轮迭代
|
||||
const paramError = validateToolArgs(call.function.name, call.function.arguments);
|
||||
const paramError = validateToolArgsQuick(call.function.name, call.function.arguments);
|
||||
if (paramError) {
|
||||
logWarn(`参数校验失败: ${call.function.name}`, paramError);
|
||||
return [{
|
||||
@@ -1600,7 +1628,6 @@ async function handleExecuting(
|
||||
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
||||
// 记录 write_file 成功路径及内容指纹,供 FileWriteDedup Hook 做内容级去重
|
||||
if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) {
|
||||
const { addWrittenFile } = await import('./hooks.js');
|
||||
addWrittenFile(String(call.function.arguments.path), String(call.function.arguments.content || ''));
|
||||
}
|
||||
return [{
|
||||
@@ -1849,7 +1876,6 @@ async function handleObserving(
|
||||
// R112: 每 15 轮输出诊断报告 + R100: Token 使用统计报告
|
||||
if (ctx.loopCount > 0 && ctx.loopCount % 15 === 0) {
|
||||
try {
|
||||
const { collectDiagnostics, formatDiagnosticsReport } = await import('./agent-safety.js');
|
||||
const diag = collectDiagnostics();
|
||||
logInfo('R112: 诊断报告\n' + formatDiagnosticsReport(diag));
|
||||
// R100: Token 使用统计报告
|
||||
@@ -2271,7 +2297,6 @@ export async function runAgentLoop(
|
||||
snapshotLoopContext(ctx);
|
||||
startSessionMetrics(sessionId, model);
|
||||
// Plan Mode 激活时注册 plan_track 工具
|
||||
const { setPlanModeActive } = await import('./tool-registry.js');
|
||||
setPlanModeActive(mode === 'plan');
|
||||
// 子代理确认管线:与主 Agent 共用同一确认回调(finally 中清理)
|
||||
setSubAgentConfirmHandler(callbacks.onConfirmTool ?? null);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - 度量仪表盘数据源
|
||||
*/
|
||||
|
||||
import { logInfo, logDebug } from './log-service.js';
|
||||
import { logInfo, logDebug, logWarn } from './log-service.js';
|
||||
import type { AgentMetrics, LoopContext } from '../types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -157,6 +157,43 @@ export function getMetricsHistory(): SessionMetrics[] {
|
||||
return [...sessionMetricsHistory];
|
||||
}
|
||||
|
||||
/** A2: 备份导出 — 读取度量历史持久化数据(供 .metona 备份携带) */
|
||||
export function getMetricsBackup(): Array<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = localStorage.getItem(METRICS_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A2: 备份导入 — 恢复度量历史持久化数据(同时写入存储并重建内存历史) */
|
||||
export function restoreMetricsBackup(data: Array<Record<string, unknown>>): void {
|
||||
if (!Array.isArray(data)) return;
|
||||
try {
|
||||
localStorage.setItem(METRICS_STORAGE_KEY, JSON.stringify(data.slice(-METRICS_HISTORY_MAX)));
|
||||
// 重建内存历史(供当前会话仪表盘立即生效)
|
||||
sessionMetricsHistory.length = 0;
|
||||
for (const item of data) {
|
||||
sessionMetricsHistory.push({
|
||||
sessionId: String(item.sessionId || 'unknown'),
|
||||
model: String(item.model || 'unknown'),
|
||||
startTime: item.timestamp ? Number(item.timestamp) - Number(item.duration || 0) : 0,
|
||||
endTime: Number(item.timestamp || 0),
|
||||
totalIterations: Number(item.iterations || 0),
|
||||
toolCalls: [],
|
||||
totalInputTokens: Number(item.inputTokens || 0),
|
||||
totalOutputTokens: Number(item.outputTokens || 0),
|
||||
errorPatterns: Array.isArray(item.errorPatterns) ? (item.errorPatterns as string[]) : [],
|
||||
});
|
||||
}
|
||||
logInfo(`R84: 从备份恢复 ${sessionMetricsHistory.length} 条历史度量`);
|
||||
} catch (err) {
|
||||
logWarn(`R84: 恢复度量历史失败: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 度量计算
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1096,43 +1096,14 @@ export function calculateContextStats(
|
||||
* - medium (30-50%): 轻量压缩(归档旧工具结果、清理 ephemeral)
|
||||
* - high (50-70%): 中等压缩(截断工具结果、合并消息)
|
||||
* - critical (>70%): LLM 压缩
|
||||
*
|
||||
* C1: 委托 unified calculateContextStats,避免与压缩决策重复计算。
|
||||
*/
|
||||
export function getContextPressureLevel(
|
||||
messages: OllamaMessage[],
|
||||
numCtx: number,
|
||||
): ContextPressureInfo {
|
||||
const totalTokens = messages.reduce((sum, m) => {
|
||||
let t = estimateTokens(m.content || '');
|
||||
if (m.tool_calls?.length) {
|
||||
for (const tc of m.tool_calls) {
|
||||
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
|
||||
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
|
||||
}
|
||||
}
|
||||
if (m.images?.length) t += m.images.length * 100;
|
||||
return sum + t;
|
||||
}, 0);
|
||||
|
||||
const ratio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||||
const msgCount = messages.length;
|
||||
const actions: string[] = [];
|
||||
|
||||
let level: ContextPressureLevel;
|
||||
if (ratio > 0.7) {
|
||||
level = 'critical';
|
||||
actions.push('llm_compress', 'truncate_results', 'compact_old', 'merge_messages', 'clear_ephemeral');
|
||||
} else if (ratio > 0.5) {
|
||||
level = 'high';
|
||||
actions.push('truncate_results', 'compact_old', 'merge_messages');
|
||||
} else if (ratio > 0.3) {
|
||||
level = 'medium';
|
||||
actions.push('compact_old', 'clear_ephemeral');
|
||||
} else {
|
||||
level = 'low';
|
||||
if (msgCount > 60) actions.push('compact_old');
|
||||
}
|
||||
|
||||
return { level, tokenUsageRatio: ratio, messageCount: msgCount, recommendedActions: actions };
|
||||
return calculateContextStats(messages, numCtx).pressureInfo;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1305,65 +1276,14 @@ export function mergeConsecutiveMessages(messages: OllamaMessage[]): OllamaMessa
|
||||
* R98: 获取结合趋势的压缩触发阈值
|
||||
* 如果 token 使用趋势在快速增长,提前触发压缩
|
||||
* 如果趋势稳定或下降,延后压缩
|
||||
*
|
||||
* C1: 委托 unified calculateContextStats 的 compressDecision,避免重复计算。
|
||||
*/
|
||||
export function getTrendAwareCompressThreshold(
|
||||
numCtx: number,
|
||||
messages: OllamaMessage[],
|
||||
): { shouldCompress: boolean; reason: string; urgency: 'low' | 'medium' | 'high' } {
|
||||
const baseThreshold = getAdaptiveCompressThreshold(numCtx);
|
||||
const currentTokens = messages.reduce((sum, m) => {
|
||||
let t = estimateTokens(m.content || '');
|
||||
if (m.tool_calls?.length) {
|
||||
for (const tc of m.tool_calls) {
|
||||
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
|
||||
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
|
||||
}
|
||||
}
|
||||
if (m.images?.length) t += m.images.length * 100;
|
||||
return sum + t;
|
||||
}, 0);
|
||||
|
||||
const usageRatio = numCtx > 0 ? currentTokens / numCtx : 0;
|
||||
const prediction = predictContextOverflow(numCtx);
|
||||
|
||||
// 紧急情况:预测即将溢出
|
||||
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
|
||||
return {
|
||||
shouldCompress: true,
|
||||
reason: `趋势预测触发: ${prediction.message}`,
|
||||
urgency: 'high',
|
||||
};
|
||||
}
|
||||
|
||||
// 趋势加速增长 + 使用率超过基础阈值
|
||||
if (prediction.turnsToOverflow > 0 && prediction.turnsToOverflow <= 5 && usageRatio > baseThreshold * 0.8) {
|
||||
return {
|
||||
shouldCompress: true,
|
||||
reason: `趋势加速: ${prediction.turnsToOverflow} 轮后可能溢出,当前使用率 ${(usageRatio * 100).toFixed(0)}%`,
|
||||
urgency: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
// 标准阈值触发
|
||||
if (usageRatio > baseThreshold) {
|
||||
return {
|
||||
shouldCompress: true,
|
||||
reason: `标准阈值触发: 使用率 ${(usageRatio * 100).toFixed(0)}% > 阈值 ${(baseThreshold * 100).toFixed(0)}%`,
|
||||
urgency: usageRatio > 0.6 ? 'high' : 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
// 消息条数硬阈值
|
||||
const msgThreshold = getIncrementalCompressThresholdMessages(numCtx);
|
||||
if (messages.length >= msgThreshold) {
|
||||
return {
|
||||
shouldCompress: true,
|
||||
reason: `消息条数触发: ${messages.length} >= ${msgThreshold}`,
|
||||
urgency: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
return { shouldCompress: false, reason: '', urgency: 'low' };
|
||||
return calculateContextStats(messages, numCtx).compressDecision;
|
||||
}
|
||||
|
||||
/** R98: 消息条数阈值(独立函数,供 engine 复用) */
|
||||
@@ -1601,6 +1521,22 @@ export function loadSessionSummaries(): SessionSummary[] {
|
||||
}
|
||||
}
|
||||
|
||||
/** A2: 备份导出 — 读取会话摘要持久化数据(供 .metona 备份携带) */
|
||||
export function getSessionSummariesBackup(): SessionSummary[] {
|
||||
return loadSessionSummaries();
|
||||
}
|
||||
|
||||
/** A2: 备份导入 — 恢复会话摘要持久化数据 */
|
||||
export function restoreSessionSummariesBackup(summaries: SessionSummary[]): void {
|
||||
if (!Array.isArray(summaries)) return;
|
||||
try {
|
||||
localStorage.setItem(SESSION_SUMMARY_KEY, JSON.stringify(summaries.slice(0, MAX_SESSION_SUMMARIES)));
|
||||
logInfo(`R123: 从备份恢复 ${summaries.length} 条会话摘要`);
|
||||
} catch (err) {
|
||||
logWarn(`R123: 恢复会话摘要失败: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** R123: 生成当前会话摘要 */
|
||||
export function generateSessionSummary(
|
||||
goal: string,
|
||||
|
||||
@@ -143,10 +143,12 @@ async function readMemoryFile(): Promise<string> {
|
||||
return result.content || '';
|
||||
}
|
||||
|
||||
/** 写入 MEMORY.md(通过专用 IPC 通道) */
|
||||
async function writeMemoryFile(content: string): Promise<void> {
|
||||
/** 写入 MEMORY.md(通过专用 IPC 通道)
|
||||
* reason: 日志标注的操作来源,用于区分"新增记忆/替换/删除/清空/访问统计写回",
|
||||
* 避免出现"文件已写入但看不到新记忆"的困惑 */
|
||||
async function writeMemoryFile(content: string, reason?: string): Promise<void> {
|
||||
const bridge = getBridge();
|
||||
const result = await bridge.memoryAccess!.write(content);
|
||||
const result = await bridge.memoryAccess!.write(content, reason);
|
||||
if (!result.success) {
|
||||
throw new Error(`写入 MEMORY.md 失败: ${result.error}`);
|
||||
}
|
||||
@@ -534,7 +536,8 @@ function scheduleHitsFlush(): void {
|
||||
_hitsFlushTimer = null;
|
||||
try {
|
||||
if (_entriesCache && _entriesCache.length > 0) {
|
||||
await writeMemoryFile(serializeMemoryMd(_entriesCache));
|
||||
// 仅为访问统计(hits/last)写回,不新增/改动记忆条目
|
||||
await writeMemoryFile(serializeMemoryMd(_entriesCache), '访问统计写回,无新条目');
|
||||
}
|
||||
} catch {
|
||||
// 写回失败不影响主流程,下次访问会再次调度
|
||||
@@ -542,11 +545,12 @@ function scheduleHitsFlush(): void {
|
||||
}, HITS_FLUSH_INTERVAL);
|
||||
}
|
||||
|
||||
/** 写入条目(同步更新缓存) */
|
||||
async function persistEntries(entries: MemoryEntry[]): Promise<void> {
|
||||
/** 写入条目(同步更新缓存)
|
||||
* reason: 操作来源(新增/替换/删除/衰减清理等),透传给主进程日志 */
|
||||
async function persistEntries(entries: MemoryEntry[], reason?: string): Promise<void> {
|
||||
_entriesCache = entries;
|
||||
const fileContent = entries.length > 0 ? serializeMemoryMd(entries) : '';
|
||||
await writeMemoryFile(fileContent);
|
||||
await writeMemoryFile(fileContent, reason);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -666,7 +670,7 @@ export async function addEntry(
|
||||
throw new Error(`序列化后校验失败: ${validation.error}`);
|
||||
}
|
||||
|
||||
await persistEntries(entries);
|
||||
await persistEntries(entries, '新增记忆');
|
||||
logMemory(`新增: ${type}`, content.slice(0, 60));
|
||||
return entry;
|
||||
});
|
||||
@@ -714,7 +718,7 @@ export async function replaceEntry(oldText: string, newContent: string): Promise
|
||||
return { success: false, message: `序列化后校验失败: ${validation.error}` };
|
||||
}
|
||||
|
||||
await persistEntries(entries);
|
||||
await persistEntries(entries, '替换记忆');
|
||||
logMemory('替换记忆', `${target.id}: ${oldText.slice(0, 30)} → ${newContent.slice(0, 30)}`);
|
||||
return { success: true, message: `已替换记忆: ${target.id}` };
|
||||
});
|
||||
@@ -742,7 +746,7 @@ export async function removeEntry(oldText: string): Promise<{ success: boolean;
|
||||
}
|
||||
|
||||
const newEntries = entries.filter(e => e.id !== matches[0].id);
|
||||
await persistEntries(newEntries);
|
||||
await persistEntries(newEntries, '删除记忆');
|
||||
|
||||
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
|
||||
return { success: true, message: `已删除记忆: ${matches[0].id}` };
|
||||
@@ -805,7 +809,7 @@ export async function removeEntries(oldTexts: string[]): Promise<{
|
||||
|
||||
const newEntries = entries.filter(e => !idsToDelete.has(e.id));
|
||||
|
||||
await persistEntries(newEntries);
|
||||
await persistEntries(newEntries, '批量删除记忆');
|
||||
|
||||
logMemory('批量删除', `删除 ${deleted} 条, 失败 ${failed} 条`);
|
||||
return {
|
||||
@@ -827,7 +831,7 @@ export async function removeEntryById(id: string): Promise<{ success: boolean; m
|
||||
return { success: false, message: `记忆 ${id} 不存在` };
|
||||
}
|
||||
const newEntries = entries.filter(e => e.id !== id);
|
||||
await persistEntries(newEntries);
|
||||
await persistEntries(newEntries, '删除记忆');
|
||||
logMemory('删除记忆', `${id}: ${target.content.slice(0, 50)}`);
|
||||
return { success: true, message: `已删除记忆: ${id}` };
|
||||
});
|
||||
@@ -838,7 +842,7 @@ export async function removeEntryById(id: string): Promise<{ success: boolean; m
|
||||
export async function clearAll(): Promise<void> {
|
||||
return withWriteLock(async () => {
|
||||
_entriesCache = [];
|
||||
await writeMemoryFile('');
|
||||
await writeMemoryFile('', '清空全部记忆');
|
||||
logMemory('清空', '所有记忆已删除');
|
||||
});
|
||||
}
|
||||
@@ -859,7 +863,7 @@ function extractTags(text: string): string[] {
|
||||
* 词级 Jaccard 相似度
|
||||
* M5: 中文文本无法用空格分词,使用字符级 bigram 相似度
|
||||
*/
|
||||
function simpleSimilarity(a: string, b: string): number {
|
||||
export function simpleSimilarity(a: string, b: string): number {
|
||||
// 判断是否包含中文
|
||||
const hasChinese = /[一-鿿]/.test(a) || /[一-鿿]/.test(b);
|
||||
|
||||
@@ -897,7 +901,7 @@ function simpleSimilarity(a: string, b: string): number {
|
||||
* 规范化文本用于去重比较:全角→半角标点、统一空白
|
||||
* 消除 AI 输出中常见的全角逗号/冒号/括号等差异
|
||||
*/
|
||||
function normalizeForDedup(text: string): string {
|
||||
export function normalizeForDedup(text: string): string {
|
||||
const FULLWIDTH_MAP: Record<string, string> = {
|
||||
'\uff0c': ',', // ,
|
||||
'\uff1a': ':', // :
|
||||
@@ -1087,7 +1091,7 @@ async function maybeRunTTLDecay(): Promise<void> {
|
||||
|
||||
const { decayed, removed, changed } = applyTTLDecay(entries);
|
||||
if (changed && removed > 0) {
|
||||
await persistEntries(decayed);
|
||||
await persistEntries(decayed, 'TTL 衰减清理');
|
||||
logMemory('TTL 衰减', `已持久化: 移除 ${removed} 条,剩余 ${decayed.length} 条`);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { TOOL_DEFINITIONS } from './tool-registry.js';
|
||||
import { getEnabledToolDefinitions, needsConfirmation } from './tool-registry.js';
|
||||
import { logInfo, logWarn, logError } from './log-service.js';
|
||||
import { validatePathSandbox, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState, classifyError, calculateBackoff } from './agent-safety.js';
|
||||
@@ -57,10 +56,12 @@ function getToolsForPermission(permission: SubAgentPermission): Set<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据权限级别获取可用工具定义 */
|
||||
/** 根据权限级别获取可用工具定义
|
||||
* C3: 以全局已启用工具为基线(含 MCP 动态工具 + plan_track 注册状态),
|
||||
* 再叠加权限白名单,确保被用户禁用的工具不会喂给子代理 LLM。 */
|
||||
function getSubAgentTools(permission: SubAgentPermission = 'readonly'): ToolDefinition[] {
|
||||
const allowed = getToolsForPermission(permission);
|
||||
return TOOL_DEFINITIONS.filter(d => allowed.has(d.function.name));
|
||||
return getEnabledToolDefinitions().filter(d => allowed.has(d.function.name));
|
||||
}
|
||||
|
||||
export interface SubAgentOptions {
|
||||
|
||||
@@ -119,7 +119,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'run_command',
|
||||
description: 'Execute a shell command via workspace. Timeout configurable (default 300s for general, 600s for long-running). cwd defaults to workspace directory. Requires user confirmation. Output is streamed in real-time through the workspace process.',
|
||||
description: 'Execute a shell command via workspace. cwd defaults to workspace directory. Output is streamed in real-time through the workspace process. Execution mode is configurable (auto / confirm / disabled) — set in the Tools panel; in confirm mode the user must approve each command before it runs.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['command'],
|
||||
|
||||
Vendored
+1
-1
@@ -238,7 +238,7 @@ export interface MetonaDesktopAPI {
|
||||
};
|
||||
memoryAccess?: {
|
||||
read: () => Promise<{ success: boolean; content?: string; error?: string }>;
|
||||
write: (content: string) => Promise<{ success: boolean; error?: string }>;
|
||||
write: (content: string, reason?: string) => Promise<{ success: boolean; error?: string }>;
|
||||
init: () => Promise<{ success: boolean; action: string; existed: boolean; valid: boolean; backedUp?: string; error?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user