diff --git a/index.js b/index.js index 4e090fe..36eb4fa 100644 --- a/index.js +++ b/index.js @@ -75,6 +75,10 @@ import { resolveAutoExtractionPlanController, runExtractionController, } from "./maintenance/extraction-controller.js"; +import { + DEFAULT_TRIGGER_KEYWORDS, + getSmartTriggerDecision, +} from "./maintenance/smart-trigger.js"; import { debugDebug, debugLog, @@ -140,7 +144,6 @@ import { refreshPanelLiveStateController, } from "./ui/panel-bridge.js"; import { - createDefaultTaskProfiles, migrateLegacyTaskProfiles, } from "./prompting/prompt-profiles.js"; import { inspectTaskRegexReuse } from "./prompting/task-regex.js"; @@ -167,6 +170,11 @@ import { writePersistedRecallToUserMessage, } from "./retrieval/recall-persistence.js"; import { resolveConfiguredTimeoutMs } from "./runtime/request-timeout.js"; +import { + defaultSettings, + getPersistedSettingsSnapshot, + mergePersistedSettings, +} from "./runtime/settings-defaults.js"; import { retrieve } from "./retrieval/retriever.js"; import { appendBatchJournal, @@ -239,6 +247,8 @@ import { validateVectorConfig, } from "./vector/vector-index.js"; +export { DEFAULT_TRIGGER_KEYWORDS, getSmartTriggerDecision }; + // 操控面板模块(动态加载,防止加载失败崩溃整个扩展) let _panelModule = null; let _themesModule = null; @@ -383,148 +393,6 @@ function readRuntimeDebugSnapshot() { ); } -// ==================== 默认设置 ==================== - -const defaultSettings = { - enabled: true, - debugLoggingEnabled: false, - timeoutMs: 300000, - hideOldMessagesEnabled: false, - hideOldMessagesKeepLastN: 12, - - // 提取设置 - extractEvery: 1, // 每 N 条 assistant 回复提取一次 - extractContextTurns: 2, // 提取时包含的上下文楼层数 - extractAutoDelayLatestAssistant: false, // 自动提取时晚一条 AI 楼再处理 - - // 召回设置 - recallEnabled: true, - recallCardUserInputDisplayMode: "beautify_only", - worldInfoFilterMode: "default", - worldInfoFilterCustomKeywords: "", - recallTopK: 20, // 向量预筛 Top-K - recallMaxNodes: 8, // LLM 召回最大节点数 - recallEnableLLM: true, // 是否启用 LLM 精确召回 - recallEnableVectorPrefilter: true, // 是否启用向量预筛 - recallEnableGraphDiffusion: true, // 是否启用图扩散 - recallDiffusionTopK: 100, // 图扩散阶段保留的候选上限 - recallLlmCandidatePool: 30, // 传给 LLM 精排的候选池大小 - recallLlmContextMessages: 4, // 传给 LLM 精排的最近非系统消息数 - recallEnableMultiIntent: true, - recallMultiIntentMaxSegments: 4, - recallEnableContextQueryBlend: true, - recallContextAssistantWeight: 0.2, - recallContextPreviousUserWeight: 0.1, - recallEnableLexicalBoost: true, - recallLexicalWeight: 0.18, - recallTeleportAlpha: 0.15, - recallEnableTemporalLinks: true, - recallTemporalLinkStrength: 0.2, - recallEnableDiversitySampling: true, - recallDppCandidateMultiplier: 3, - recallDppQualityWeight: 1.0, - recallEnableCooccurrenceBoost: false, - recallCooccurrenceScale: 0.1, - recallCooccurrenceMaxNeighbors: 10, - recallEnableResidualRecall: false, - recallResidualBasisMaxNodes: 24, - recallNmfTopics: 15, - recallNmfNoveltyThreshold: 0.4, - recallResidualThreshold: 0.3, - recallResidualTopK: 5, - enableScopedMemory: true, - enablePovMemory: true, - enableRegionScopedObjective: true, - recallCharacterPovWeight: 1.25, - recallUserPovWeight: 1.05, - recallObjectiveCurrentRegionWeight: 1.15, - recallObjectiveAdjacentRegionWeight: 0.9, - recallObjectiveGlobalWeight: 0.75, - injectUserPovMemory: true, - injectObjectiveGlobalMemory: true, - - // 注入设置 - injectPosition: "atDepth", // 注入位置 - injectDepth: 9999, // IN_CHAT@Depth 注入深度,数值越大越靠前 - injectRole: 0, // 0=system, 1=user, 2=assistant - - // 混合评分权重 - graphWeight: 0.6, - vectorWeight: 0.3, - importanceWeight: 0.1, - - // 记忆 LLM(留空时复用当前酒馆模型) - llmApiUrl: "", - llmApiKey: "", - llmModel: "", - llmPresets: {}, - llmActivePreset: "", - - // Embedding API 配置 - embeddingApiUrl: "", - embeddingApiKey: "", - embeddingModel: "text-embedding-3-small", - embeddingTransportMode: "direct", - embeddingBackendSource: "openai", - embeddingBackendModel: "text-embedding-3-small", - embeddingBackendApiUrl: "", - embeddingAutoSuffix: true, - - // Schema - nodeTypeSchema: null, // null 表示使用默认 - - // 自定义提示词 - extractPrompt: "", - recallPrompt: "", - consolidationPrompt: "", - compressPrompt: "", - synopsisPrompt: "", - reflectionPrompt: "", - taskProfilesVersion: 3, - taskProfiles: createDefaultTaskProfiles(), - - // ====== v2 增强设置 ====== - - // ③ 记忆整合(合并精确对照 + 记忆进化) - enableConsolidation: true, // 启用记忆整合 - consolidationNeighborCount: 5, // 近邻搜索数量 - consolidationThreshold: 0.85, // 冲突判定相似度阈值 - - // ⑨ 全局故事概要 - enableSynopsis: true, // 启用全局概要 - synopsisEveryN: 5, // 每 N 次提取后更新概要 - - // ⑥ 认知边界过滤(P1) - enableVisibility: true, // 启用认知边界 - // ⑦ 双记忆交叉检索(P1) - enableCrossRecall: true, // 启用交叉检索 - - // ① 惊奇度分割(P2) - enableSmartTrigger: false, // 启用惊奇度分割 - triggerPatterns: "", // 自定义触发正则 - smartTriggerThreshold: 2, // 轻量触发阈值 - - // ⑤ 主动遗忘(P2) - enableSleepCycle: false, // 启用主动遗忘 - forgetThreshold: 0.5, // 保留价值阈值 - sleepEveryN: 10, // 每 N 次提取后执行 - - // ⑧ 概率触发回忆(P2) - enableProbRecall: false, // 启用概率触发 - probRecallChance: 0.15, // 触发概率 - - // ⑩ 反思条目(P2) - enableReflection: true, // 启用反思 - reflectEveryN: 10, // 每 N 次提取后反思 - consolidationAutoMinNewNodes: 2, - enableAutoCompression: true, - compressionEveryN: 10, - - // UI 面板 - noticeDisplayMode: "normal", // normal|compact - panelTheme: "crimson", // 面板主题 crimson|cyan|amber|violet|paperDawn|glacierSky -}; - // ==================== 状态 ==================== let currentGraph = null; @@ -2989,50 +2857,10 @@ function installSendIntentHooks() { // ==================== 设置管理 ==================== -function migrateLegacyAutoMaintenanceSettings(loaded = {}) { - if (!loaded || typeof loaded !== "object" || Array.isArray(loaded)) { - return {}; - } - - const migrated = { ...loaded }; - if ( - !Object.prototype.hasOwnProperty.call( - migrated, - "consolidationAutoMinNewNodes", - ) && - Object.prototype.hasOwnProperty.call(migrated, "maintenanceAutoMinNewNodes") - ) { - migrated.consolidationAutoMinNewNodes = clampInt( - migrated.maintenanceAutoMinNewNodes, - defaultSettings.consolidationAutoMinNewNodes, - 1, - 50, - ); - } - if (!Object.prototype.hasOwnProperty.call(migrated, "enableAutoCompression")) { - const parsedEveryN = Math.floor(Number(migrated.compressionEveryN)); - migrated.enableAutoCompression = !( - Number.isFinite(parsedEveryN) && parsedEveryN <= 0 - ); - } - if ( - Object.prototype.hasOwnProperty.call(migrated, "compressionEveryN") && - Math.floor(Number(migrated.compressionEveryN)) <= 0 - ) { - migrated.compressionEveryN = defaultSettings.compressionEveryN; - } - delete migrated.maintenanceAutoMinNewNodes; - return migrated; -} - function getSettings() { - const loadedSettings = migrateLegacyAutoMaintenanceSettings( + const mergedSettings = mergePersistedSettings( extension_settings[MODULE_NAME] || {}, ); - const mergedSettings = { - ...defaultSettings, - ...loadedSettings, - }; const migrated = migrateLegacyTaskProfiles(mergedSettings); mergedSettings.taskProfilesVersion = migrated.taskProfilesVersion; mergedSettings.taskProfiles = migrated.taskProfiles; @@ -6800,25 +6628,6 @@ async function resetVectorStateForConfigChange(reason = "向量配置已变更") saveGraphToChat({ reason: "vector-config-reset" }); } -function getPersistedSettingsSnapshot(settings = getSettings()) { - const persisted = {}; - for (const key of Object.keys(defaultSettings)) { - persisted[key] = settings[key]; - } - return persisted; -} - -function mergePersistedSettings(loaded = {}) { - const compatibleLoaded = migrateLegacyAutoMaintenanceSettings(loaded); - const merged = { ...defaultSettings }; - for (const key of Object.keys(defaultSettings)) { - if (Object.prototype.hasOwnProperty.call(compatibleLoaded, key)) { - merged[key] = compatibleLoaded[key]; - } - } - return merged; -} - function encodeBase64Utf8(text) { const bytes = new TextEncoder().encode(String(text ?? "")); const chunkSize = 0x8000; @@ -7722,120 +7531,6 @@ function handleGraphShadowSnapshotVisibilityChange() { // ==================== 核心流程 ==================== -const DEFAULT_TRIGGER_KEYWORDS = [ - "突然", - "没想到", - "原来", - "其实", - "发现", - "背叛", - "死亡", - "复活", - "恢复记忆", - "失忆", - "告白", - "暴露", - "秘密", - "计划", - "规则", - "契约", - "位置", - "地点", - "离开", - "来到", -]; - -export function getSmartTriggerDecision( - chat, - lastProcessed, - settings, - endFloor = null, -) { - const startFloor = Math.max(0, (lastProcessed ?? -1) + 1); - const normalizedEndFloor = Number.isFinite(Number(endFloor)) - ? Math.max(startFloor - 1, Math.floor(Number(endFloor))) - : null; - const pendingMessages = chat - .slice( - startFloor, - normalizedEndFloor == null ? undefined : normalizedEndFloor + 1, - ) - .map((msg, offset) => ({ - msg, - index: startFloor + offset, - })) - .filter(({ msg, index }) => !isSystemMessageForExtraction(msg, { index, chat })) - .map(({ msg }) => ({ - role: msg.is_user ? "user" : "assistant", - content: msg.mes || "", - })) - .filter((msg) => msg.content.trim().length > 0); - - if (pendingMessages.length === 0) { - return { triggered: false, score: 0, reasons: [] }; - } - - const reasons = []; - let score = 0; - const combinedText = pendingMessages.map((m) => m.content).join("\n"); - - const keywordHits = DEFAULT_TRIGGER_KEYWORDS.filter((keyword) => - combinedText.includes(keyword), - ); - if (keywordHits.length > 0) { - score += Math.min(2, keywordHits.length); - reasons.push(`关键词: ${keywordHits.slice(0, 3).join(", ")}`); - } - - const customPatterns = String(settings.triggerPatterns || "") - .split(/\r?\n|,/) - .map((s) => s.trim()) - .filter(Boolean); - for (const pattern of customPatterns) { - try { - const regex = new RegExp(pattern, "i"); - if (regex.test(combinedText)) { - score += 2; - reasons.push(`自定义触发: ${pattern}`); - break; - } - } catch { - // 忽略无效正则,避免影响主流程 - } - } - - const roleSwitchCount = pendingMessages.reduce((count, message, index) => { - if (index === 0) return count; - return count + (message.role !== pendingMessages[index - 1].role ? 1 : 0); - }, 0); - if (roleSwitchCount >= 2) { - score += 1; - reasons.push("多轮往返互动"); - } - - const punctuationHits = (combinedText.match(/[!?!?]/g) || []).length; - if (punctuationHits >= 2) { - score += 1; - reasons.push("情绪/冲突波动"); - } - - const entityLikeHits = - combinedText.match( - /[A-Z][a-z]{2,}|[\u4e00-\u9fff]{2,6}(先生|小姐|王国|城|镇|村|学院|组织|公司|小队|军团)/g, - ) || []; - if (entityLikeHits.length > 0) { - score += 1; - reasons.push("疑似新实体/新地点"); - } - - const threshold = Math.max(1, settings.smartTriggerThreshold || 2); - return { - triggered: score >= threshold, - score, - reasons, - }; -} - function getLatestUserChatMessage(chat) { if (!Array.isArray(chat)) return null; diff --git a/maintenance/smart-trigger.js b/maintenance/smart-trigger.js new file mode 100644 index 0000000..0bd2211 --- /dev/null +++ b/maintenance/smart-trigger.js @@ -0,0 +1,121 @@ +import { isSystemMessageForExtraction } from "./chat-history.js"; + +export const DEFAULT_TRIGGER_KEYWORDS = [ + "突然", + "没想到", + "原来", + "其实", + "发现", + "背叛", + "死亡", + "复活", + "恢复记忆", + "失忆", + "告白", + "暴露", + "秘密", + "计划", + "规则", + "契约", + "位置", + "地点", + "离开", + "来到", +]; + +export function getSmartTriggerDecision( + chat, + lastProcessed, + settings, + endFloor = null, +) { + const safeChat = Array.isArray(chat) ? chat : []; + const startFloor = Math.max(0, (lastProcessed ?? -1) + 1); + const normalizedEndFloor = + endFloor == null || endFloor === "" + ? null + : Number.isFinite(Number(endFloor)) + ? Math.max(startFloor - 1, Math.floor(Number(endFloor))) + : null; + const pendingMessages = safeChat + .slice( + startFloor, + normalizedEndFloor == null ? undefined : normalizedEndFloor + 1, + ) + .map((msg, offset) => ({ + msg, + index: startFloor + offset, + })) + .filter(({ msg, index }) => + !isSystemMessageForExtraction(msg, { index, chat: safeChat }), + ) + .map(({ msg }) => ({ + role: msg.is_user ? "user" : "assistant", + content: msg.mes || "", + })) + .filter((msg) => msg.content.trim().length > 0); + + if (pendingMessages.length === 0) { + return { triggered: false, score: 0, reasons: [] }; + } + + const reasons = []; + let score = 0; + const combinedText = pendingMessages.map((message) => message.content).join("\n"); + + const keywordHits = DEFAULT_TRIGGER_KEYWORDS.filter((keyword) => + combinedText.includes(keyword), + ); + if (keywordHits.length > 0) { + score += Math.min(2, keywordHits.length); + reasons.push(`关键词: ${keywordHits.slice(0, 3).join(", ")}`); + } + + const customPatterns = String(settings?.triggerPatterns || "") + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); + for (const pattern of customPatterns) { + try { + const regex = new RegExp(pattern, "i"); + if (regex.test(combinedText)) { + score += 2; + reasons.push(`自定义触发: ${pattern}`); + break; + } + } catch { + // 忽略无效正则,避免影响主流程 + } + } + + const roleSwitchCount = pendingMessages.reduce((count, message, index) => { + if (index === 0) return count; + return count + (message.role !== pendingMessages[index - 1].role ? 1 : 0); + }, 0); + if (roleSwitchCount >= 2) { + score += 1; + reasons.push("多轮往返互动"); + } + + const punctuationHits = (combinedText.match(/[!?!?]/g) || []).length; + if (punctuationHits >= 2) { + score += 1; + reasons.push("情绪/冲突波动"); + } + + const entityLikeHits = + combinedText.match( + /[A-Z][a-z]{2,}|[\u4e00-\u9fff]{2,6}(先生|小姐|王国|城|镇|村|学院|组织|公司|小队|军团)/g, + ) || []; + if (entityLikeHits.length > 0) { + score += 1; + reasons.push("疑似新实体/新地点"); + } + + const threshold = Math.max(1, settings?.smartTriggerThreshold || 2); + return { + triggered: score >= threshold, + score, + reasons, + }; +} diff --git a/package.json b/package.json index 50632f2..dc840c5 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "test:trivial-input": "node tests/trivial-user-input.mjs", "test:indexeddb": "npm run test:indexeddb-persistence && npm run test:indexeddb-sync && npm run test:indexeddb-migration", "test:persistence-matrix": "npm run test:p0 && npm run test:runtime-history && npm run test:graph-persistence && npm run test:indexeddb", - "test:all": "npm run test:persistence-matrix && npm run test:maintenance-journal && npm run test:trivial-input", - "check": "node --check index.js && node --check sync/bme-db.js && node --check ui/hide-engine.js && node --check ui/panel.js && node --check ui/ui-status.js && node --check host/event-binding.js" + "test:stable": "node scripts/run-test-suite.mjs", + "test:all": "npm run test:stable", + "check": "node scripts/check-syntax.mjs" }, "dependencies": { "triviumdb": "^0.4.41" diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js new file mode 100644 index 0000000..02711b7 --- /dev/null +++ b/runtime/settings-defaults.js @@ -0,0 +1,189 @@ +import { createDefaultTaskProfiles } from "../prompting/prompt-profiles.js"; + +function clampIntValue(value, fallback = 0, min = 0, max = 9999) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(numeric))); +} + +export const defaultSettings = { + enabled: true, + debugLoggingEnabled: false, + timeoutMs: 300000, + hideOldMessagesEnabled: false, + hideOldMessagesKeepLastN: 12, + + // 提取设置 + extractEvery: 1, + extractContextTurns: 2, + extractAutoDelayLatestAssistant: false, + + // 召回设置 + recallEnabled: true, + recallCardUserInputDisplayMode: "beautify_only", + worldInfoFilterMode: "default", + worldInfoFilterCustomKeywords: "", + recallTopK: 20, + recallMaxNodes: 8, + recallEnableLLM: true, + recallEnableVectorPrefilter: true, + recallEnableGraphDiffusion: true, + recallDiffusionTopK: 100, + recallLlmCandidatePool: 30, + recallLlmContextMessages: 4, + recallEnableMultiIntent: true, + recallMultiIntentMaxSegments: 4, + recallEnableContextQueryBlend: true, + recallContextAssistantWeight: 0.2, + recallContextPreviousUserWeight: 0.1, + recallEnableLexicalBoost: true, + recallLexicalWeight: 0.18, + recallTeleportAlpha: 0.15, + recallEnableTemporalLinks: true, + recallTemporalLinkStrength: 0.2, + recallEnableDiversitySampling: true, + recallDppCandidateMultiplier: 3, + recallDppQualityWeight: 1.0, + recallEnableCooccurrenceBoost: false, + recallCooccurrenceScale: 0.1, + recallCooccurrenceMaxNeighbors: 10, + recallEnableResidualRecall: false, + recallResidualBasisMaxNodes: 24, + recallNmfTopics: 15, + recallNmfNoveltyThreshold: 0.4, + recallResidualThreshold: 0.3, + recallResidualTopK: 5, + enableScopedMemory: true, + enablePovMemory: true, + enableRegionScopedObjective: true, + recallCharacterPovWeight: 1.25, + recallUserPovWeight: 1.05, + recallObjectiveCurrentRegionWeight: 1.15, + recallObjectiveAdjacentRegionWeight: 0.9, + recallObjectiveGlobalWeight: 0.75, + injectUserPovMemory: true, + injectObjectiveGlobalMemory: true, + + // 注入设置 + injectPosition: "atDepth", + injectDepth: 9999, + injectRole: 0, + + // 混合评分权重 + graphWeight: 0.6, + vectorWeight: 0.3, + importanceWeight: 0.1, + + // 记忆 LLM(留空时复用当前酒馆模型) + llmApiUrl: "", + llmApiKey: "", + llmModel: "", + llmPresets: {}, + llmActivePreset: "", + + // Embedding API 配置 + embeddingApiUrl: "", + embeddingApiKey: "", + embeddingModel: "text-embedding-3-small", + embeddingTransportMode: "direct", + embeddingBackendSource: "openai", + embeddingBackendModel: "text-embedding-3-small", + embeddingBackendApiUrl: "", + embeddingAutoSuffix: true, + + // Schema + nodeTypeSchema: null, + + // 自定义提示词 + extractPrompt: "", + recallPrompt: "", + consolidationPrompt: "", + compressPrompt: "", + synopsisPrompt: "", + reflectionPrompt: "", + taskProfilesVersion: 3, + taskProfiles: createDefaultTaskProfiles(), + + // ====== v2 增强设置 ====== + enableConsolidation: true, + consolidationNeighborCount: 5, + consolidationThreshold: 0.85, + enableSynopsis: true, + synopsisEveryN: 5, + enableVisibility: true, + enableCrossRecall: true, + enableSmartTrigger: false, + triggerPatterns: "", + smartTriggerThreshold: 2, + enableSleepCycle: false, + forgetThreshold: 0.5, + sleepEveryN: 10, + enableProbRecall: false, + probRecallChance: 0.15, + enableReflection: true, + reflectEveryN: 10, + consolidationAutoMinNewNodes: 2, + enableAutoCompression: true, + compressionEveryN: 10, + + // UI 面板 + noticeDisplayMode: "normal", + panelTheme: "crimson", +}; + +const DEFAULT_SETTING_KEYS = Object.freeze(Object.keys(defaultSettings)); + +export function migrateLegacyAutoMaintenanceSettings(loaded = {}) { + if (!loaded || typeof loaded !== "object" || Array.isArray(loaded)) { + return {}; + } + + const migrated = { ...loaded }; + if ( + !Object.prototype.hasOwnProperty.call( + migrated, + "consolidationAutoMinNewNodes", + ) && + Object.prototype.hasOwnProperty.call(migrated, "maintenanceAutoMinNewNodes") + ) { + migrated.consolidationAutoMinNewNodes = clampIntValue( + migrated.maintenanceAutoMinNewNodes, + defaultSettings.consolidationAutoMinNewNodes, + 1, + 50, + ); + } + if (!Object.prototype.hasOwnProperty.call(migrated, "enableAutoCompression")) { + const parsedEveryN = Math.floor(Number(migrated.compressionEveryN)); + migrated.enableAutoCompression = !( + Number.isFinite(parsedEveryN) && parsedEveryN <= 0 + ); + } + if ( + Object.prototype.hasOwnProperty.call(migrated, "compressionEveryN") && + Math.floor(Number(migrated.compressionEveryN)) <= 0 + ) { + migrated.compressionEveryN = defaultSettings.compressionEveryN; + } + delete migrated.maintenanceAutoMinNewNodes; + return migrated; +} + +export function mergePersistedSettings(loaded = {}) { + const compatibleLoaded = migrateLegacyAutoMaintenanceSettings(loaded); + const merged = { ...defaultSettings }; + for (const key of DEFAULT_SETTING_KEYS) { + if (Object.prototype.hasOwnProperty.call(compatibleLoaded, key)) { + merged[key] = compatibleLoaded[key]; + } + } + return merged; +} + +export function getPersistedSettingsSnapshot(settings = defaultSettings) { + const persisted = {}; + for (const key of DEFAULT_SETTING_KEYS) { + persisted[key] = settings[key]; + } + return persisted; +} diff --git a/scripts/check-syntax.mjs b/scripts/check-syntax.mjs new file mode 100644 index 0000000..2944df6 --- /dev/null +++ b/scripts/check-syntax.mjs @@ -0,0 +1,95 @@ +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const SOURCE_ROOTS = [ + "index.js", + "ena-planner", + "graph", + "host", + "llm", + "maintenance", + "prompting", + "retrieval", + "runtime", + "scripts", + "sync", + "ui", + "vector", +]; + +async function collectFiles(targetPath) { + const absolutePath = path.resolve(process.cwd(), targetPath); + const fileStat = await stat(absolutePath); + if (fileStat.isFile()) { + return [absolutePath]; + } + + const files = []; + const entries = await readdir(absolutePath, { withFileTypes: true }); + for (const entry of entries) { + const nextRelative = path.join(targetPath, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectFiles(nextRelative))); + continue; + } + if (entry.isFile() && /\.(js|mjs)$/.test(entry.name)) { + files.push(path.resolve(process.cwd(), nextRelative)); + } + } + return files; +} + +function toPosixPath(filePath) { + return path.relative(process.cwd(), filePath).split(path.sep).join("/"); +} + +async function runNodeCheck(filePath) { + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--check", filePath], { + cwd: process.cwd(), + stdio: "inherit", + windowsHide: true, + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`${filePath} terminated by signal ${signal}`)); + return; + } + if (code !== 0) { + reject(new Error(`${filePath} exited with code ${code}`)); + return; + } + resolve(); + }); + }); +} + +async function main() { + const files = []; + for (const root of SOURCE_ROOTS) { + files.push(...(await collectFiles(root))); + } + + const uniqueFiles = Array.from(new Set(files)).sort((left, right) => + toPosixPath(left).localeCompare(toPosixPath(right), "en"), + ); + console.log(`[ST-BME][check] syntax-checking ${uniqueFiles.length} files`); + + for (const filePath of uniqueFiles) { + console.log(`[ST-BME][check] -> ${toPosixPath(filePath)}`); + await runNodeCheck(filePath); + } + + console.log("[ST-BME][check] syntax checks passed"); +} + +main().catch((error) => { + console.error( + "[ST-BME][check] failed:", + error instanceof Error ? error.message : String(error), + ); + process.exitCode = 1; +}); diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs new file mode 100644 index 0000000..b24e212 --- /dev/null +++ b/scripts/run-test-suite.mjs @@ -0,0 +1,65 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const TEST_ROOT = path.resolve(process.cwd(), "tests"); +const EXCLUDED_TESTS = new Set(["triviumdb-poc.mjs"]); + +function toPosixPath(filePath) { + return filePath.split(path.sep).join("/"); +} + +async function collectStableTests() { + const entries = await readdir(TEST_ROOT, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".mjs")) + .map((entry) => entry.name) + .filter((name) => !EXCLUDED_TESTS.has(name)) + .sort((left, right) => left.localeCompare(right, "en")); +} + +async function runNodeFile(relativePath) { + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [relativePath], { + cwd: process.cwd(), + stdio: "inherit", + windowsHide: true, + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`${relativePath} terminated by signal ${signal}`)); + return; + } + if (code !== 0) { + reject(new Error(`${relativePath} exited with code ${code}`)); + return; + } + resolve(); + }); + }); +} + +async function main() { + const tests = await collectStableTests(); + console.log( + `[ST-BME][test-suite] running ${tests.length} stable tests (excluded: ${Array.from(EXCLUDED_TESTS).join(", ")})`, + ); + + for (const testName of tests) { + const relativePath = toPosixPath(path.join("tests", testName)); + console.log(`[ST-BME][test-suite] -> ${relativePath}`); + await runNodeFile(relativePath); + } + + console.log("[ST-BME][test-suite] all stable tests passed"); +} + +main().catch((error) => { + console.error( + "[ST-BME][test-suite] failed:", + error instanceof Error ? error.message : String(error), + ); + process.exitCode = 1; +}); diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index abb2725..59e0093 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -1,86 +1,9 @@ import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import vm from "node:vm"; -async function loadDefaultSettings() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const indexPath = path.resolve(__dirname, "../index.js"); - const source = await fs.readFile(indexPath, "utf8"); - const settingsMatch = source.match(/const defaultSettings = \{[\s\S]*?^\};/m); - - if (!settingsMatch) { - throw new Error("无法从 index.js 提取 defaultSettings"); - } - - const context = vm.createContext({ - createDefaultTaskProfiles() { - return { - extract: { activeProfileId: "default", profiles: [] }, - recall: { activeProfileId: "default", profiles: [] }, - compress: { activeProfileId: "default", profiles: [] }, - synopsis: { activeProfileId: "default", profiles: [] }, - reflection: { activeProfileId: "default", profiles: [] }, - consolidation: { activeProfileId: "default", profiles: [] }, - }; - }, - }); - const script = new vm.Script(` -${settingsMatch[0]} -this.defaultSettings = defaultSettings; -`); - script.runInContext(context); - return context.defaultSettings; -} - -async function loadSettingsCompatHelpers() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const indexPath = path.resolve(__dirname, "../index.js"); - const source = await fs.readFile(indexPath, "utf8"); - const settingsMatch = source.match(/const defaultSettings = \{[\s\S]*?^\};/m); - const compatMatch = source.match( - /function migrateLegacyAutoMaintenanceSettings\(loaded = \{\}\) \{[\s\S]*?^}\r?\n/m, - ); - const mergeMatch = source.match( - /function mergePersistedSettings\(loaded = \{\}\) \{[\s\S]*?^}\r?\n/m, - ); - - if (!settingsMatch || !compatMatch || !mergeMatch) { - throw new Error("无法从 index.js 提取设置兼容辅助函数"); - } - - const context = vm.createContext({ - clampInt: (value, fallback = 0, min = 0, max = 9999) => { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return fallback; - return Math.min(max, Math.max(min, Math.trunc(numeric))); - }, - createDefaultTaskProfiles() { - return { - extract: { activeProfileId: "default", profiles: [] }, - recall: { activeProfileId: "default", profiles: [] }, - compress: { activeProfileId: "default", profiles: [] }, - synopsis: { activeProfileId: "default", profiles: [] }, - reflection: { activeProfileId: "default", profiles: [] }, - consolidation: { activeProfileId: "default", profiles: [] }, - }; - }, - }); - const script = new vm.Script(` -${settingsMatch[0]} -${compatMatch[0]} -${mergeMatch[0]} -this.mergePersistedSettings = mergePersistedSettings; -`); - script.runInContext(context); - return { - mergePersistedSettings: context.mergePersistedSettings, - }; -} - -const defaultSettings = await loadDefaultSettings(); -const { mergePersistedSettings } = await loadSettingsCompatHelpers(); +import { + defaultSettings, + mergePersistedSettings, +} from "../runtime/settings-defaults.js"; assert.equal(defaultSettings.extractContextTurns, 2); assert.equal(defaultSettings.extractAutoDelayLatestAssistant, false); diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 2cfb247..93f893b 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -49,6 +49,11 @@ import { } from "../retrieval/recall-persistence.js"; import { getNodeDisplayName } from "../graph/node-labels.js"; import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js"; +import { + defaultSettings, + getPersistedSettingsSnapshot, + mergePersistedSettings, +} from "../runtime/settings-defaults.js"; import { clampFloat, clampInt, @@ -276,6 +281,9 @@ async function createGraphPersistenceHarness({ extension_settings: { [MODULE_NAME]: {}, }, + defaultSettings, + getPersistedSettingsSnapshot, + mergePersistedSettings, migrateLegacyTaskProfiles(settings = {}) { return { taskProfilesVersion: Number(settings?.taskProfilesVersion || 0), diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index a2eaad2..560ca0c 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -16,6 +16,7 @@ import { GRAPH_PERSISTENCE_META_KEY, MODULE_NAME, } from "../../graph/graph-persistence.js"; +import { getSmartTriggerDecision } from "../../maintenance/smart-trigger.js"; import { buildPersistedRecallRecord, bumpPersistedRecallGenerationCount, @@ -40,6 +41,10 @@ import { normalizeStageNoticeLevel, shouldRunRecallForTransaction, } from "../../ui/ui-status.js"; +import { + defaultSettings, + mergePersistedSettings, +} from "../../runtime/settings-defaults.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const indexPath = path.resolve(moduleDir, "../../index.js"); @@ -81,7 +86,8 @@ export function createGenerationRecallHarness(options = {}) { result: null, currentGraph: {}, _panelModule: null, - defaultSettings: {}, + defaultSettings, + mergePersistedSettings, settings: {}, graphPersistenceState: createGraphPersistenceState(), extension_settings: { [MODULE_NAME]: {} }, @@ -121,6 +127,7 @@ export function createGenerationRecallHarness(options = {}) { [...chat].reverse().find((message) => message?.is_user) || null, getLastNonSystemChatMessage: (chat = []) => [...chat].reverse().find((message) => !message?.is_system) || null, + getSmartTriggerDecision, getSendTextareaValue: () => context.__sendTextareaValue, getRecallUserMessageSourceLabel: (source = "") => source, getRecallUserMessageSourceLabelController: (source = "") => source, diff --git a/tests/smart-trigger.mjs b/tests/smart-trigger.mjs index a51d5f6..602741f 100644 --- a/tests/smart-trigger.mjs +++ b/tests/smart-trigger.mjs @@ -1,35 +1,6 @@ import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import vm from "node:vm"; -async function loadSmartTriggerDecision() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const indexPath = path.resolve(__dirname, "../index.js"); - const source = await fs.readFile(indexPath, "utf8"); - const keywordMatch = source.match( - /const DEFAULT_TRIGGER_KEYWORDS = \[[\s\S]*?\];/m, - ); - const fnMatch = source.match( - /export function getSmartTriggerDecision\(chat, lastProcessed, settings\) \{[\s\S]*?^\}/m, - ); - - if (!keywordMatch || !fnMatch) { - throw new Error("无法从 index.js 提取 smart trigger 实现"); - } - - const context = vm.createContext({}); - const script = new vm.Script(` -${keywordMatch[0]} -${fnMatch[0].replace("export function", "function")} -this.getSmartTriggerDecision = getSmartTriggerDecision; -`); - script.runInContext(context); - return context.getSmartTriggerDecision; -} - -const getSmartTriggerDecision = await loadSmartTriggerDecision(); +import { getSmartTriggerDecision } from "../maintenance/smart-trigger.js"; const noTrigger = getSmartTriggerDecision( [ @@ -61,7 +32,7 @@ const customTrigger = getSmartTriggerDecision( { triggerPatterns: "真相|背叛", smartTriggerThreshold: 2 }, ); assert.equal(customTrigger.triggered, true); -assert.ok(customTrigger.reasons.some((r) => r.includes("自定义触发"))); +assert.ok(customTrigger.reasons.some((reason) => reason.includes("自定义触发"))); const ignoresProcessedMessages = getSmartTriggerDecision( [