Harden post-refactor test and check guardrails

This commit is contained in:
Youzini-afk
2026-04-08 03:02:55 +08:00
parent 6060416c17
commit 9939734bcb
10 changed files with 507 additions and 432 deletions

329
index.js
View File

@@ -75,6 +75,10 @@ import {
resolveAutoExtractionPlanController, resolveAutoExtractionPlanController,
runExtractionController, runExtractionController,
} from "./maintenance/extraction-controller.js"; } from "./maintenance/extraction-controller.js";
import {
DEFAULT_TRIGGER_KEYWORDS,
getSmartTriggerDecision,
} from "./maintenance/smart-trigger.js";
import { import {
debugDebug, debugDebug,
debugLog, debugLog,
@@ -140,7 +144,6 @@ import {
refreshPanelLiveStateController, refreshPanelLiveStateController,
} from "./ui/panel-bridge.js"; } from "./ui/panel-bridge.js";
import { import {
createDefaultTaskProfiles,
migrateLegacyTaskProfiles, migrateLegacyTaskProfiles,
} from "./prompting/prompt-profiles.js"; } from "./prompting/prompt-profiles.js";
import { inspectTaskRegexReuse } from "./prompting/task-regex.js"; import { inspectTaskRegexReuse } from "./prompting/task-regex.js";
@@ -167,6 +170,11 @@ import {
writePersistedRecallToUserMessage, writePersistedRecallToUserMessage,
} from "./retrieval/recall-persistence.js"; } from "./retrieval/recall-persistence.js";
import { resolveConfiguredTimeoutMs } from "./runtime/request-timeout.js"; import { resolveConfiguredTimeoutMs } from "./runtime/request-timeout.js";
import {
defaultSettings,
getPersistedSettingsSnapshot,
mergePersistedSettings,
} from "./runtime/settings-defaults.js";
import { retrieve } from "./retrieval/retriever.js"; import { retrieve } from "./retrieval/retriever.js";
import { import {
appendBatchJournal, appendBatchJournal,
@@ -239,6 +247,8 @@ import {
validateVectorConfig, validateVectorConfig,
} from "./vector/vector-index.js"; } from "./vector/vector-index.js";
export { DEFAULT_TRIGGER_KEYWORDS, getSmartTriggerDecision };
// 操控面板模块(动态加载,防止加载失败崩溃整个扩展) // 操控面板模块(动态加载,防止加载失败崩溃整个扩展)
let _panelModule = null; let _panelModule = null;
let _themesModule = 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; 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() { function getSettings() {
const loadedSettings = migrateLegacyAutoMaintenanceSettings( const mergedSettings = mergePersistedSettings(
extension_settings[MODULE_NAME] || {}, extension_settings[MODULE_NAME] || {},
); );
const mergedSettings = {
...defaultSettings,
...loadedSettings,
};
const migrated = migrateLegacyTaskProfiles(mergedSettings); const migrated = migrateLegacyTaskProfiles(mergedSettings);
mergedSettings.taskProfilesVersion = migrated.taskProfilesVersion; mergedSettings.taskProfilesVersion = migrated.taskProfilesVersion;
mergedSettings.taskProfiles = migrated.taskProfiles; mergedSettings.taskProfiles = migrated.taskProfiles;
@@ -6800,25 +6628,6 @@ async function resetVectorStateForConfigChange(reason = "向量配置已变更")
saveGraphToChat({ reason: "vector-config-reset" }); 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) { function encodeBase64Utf8(text) {
const bytes = new TextEncoder().encode(String(text ?? "")); const bytes = new TextEncoder().encode(String(text ?? ""));
const chunkSize = 0x8000; 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) { function getLatestUserChatMessage(chat) {
if (!Array.isArray(chat)) return null; if (!Array.isArray(chat)) return null;

View File

@@ -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,
};
}

View File

@@ -12,8 +12,9 @@
"test:trivial-input": "node tests/trivial-user-input.mjs", "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: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: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", "test:stable": "node scripts/run-test-suite.mjs",
"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:all": "npm run test:stable",
"check": "node scripts/check-syntax.mjs"
}, },
"dependencies": { "dependencies": {
"triviumdb": "^0.4.41" "triviumdb": "^0.4.41"

View File

@@ -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;
}

95
scripts/check-syntax.mjs Normal file
View File

@@ -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;
});

View File

@@ -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;
});

View File

@@ -1,86 +1,9 @@
import assert from "node:assert/strict"; 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() { import {
const __dirname = path.dirname(fileURLToPath(import.meta.url)); defaultSettings,
const indexPath = path.resolve(__dirname, "../index.js"); mergePersistedSettings,
const source = await fs.readFile(indexPath, "utf8"); } from "../runtime/settings-defaults.js";
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();
assert.equal(defaultSettings.extractContextTurns, 2); assert.equal(defaultSettings.extractContextTurns, 2);
assert.equal(defaultSettings.extractAutoDelayLatestAssistant, false); assert.equal(defaultSettings.extractAutoDelayLatestAssistant, false);

View File

@@ -49,6 +49,11 @@ import {
} from "../retrieval/recall-persistence.js"; } from "../retrieval/recall-persistence.js";
import { getNodeDisplayName } from "../graph/node-labels.js"; import { getNodeDisplayName } from "../graph/node-labels.js";
import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js"; import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js";
import {
defaultSettings,
getPersistedSettingsSnapshot,
mergePersistedSettings,
} from "../runtime/settings-defaults.js";
import { import {
clampFloat, clampFloat,
clampInt, clampInt,
@@ -276,6 +281,9 @@ async function createGraphPersistenceHarness({
extension_settings: { extension_settings: {
[MODULE_NAME]: {}, [MODULE_NAME]: {},
}, },
defaultSettings,
getPersistedSettingsSnapshot,
mergePersistedSettings,
migrateLegacyTaskProfiles(settings = {}) { migrateLegacyTaskProfiles(settings = {}) {
return { return {
taskProfilesVersion: Number(settings?.taskProfilesVersion || 0), taskProfilesVersion: Number(settings?.taskProfilesVersion || 0),

View File

@@ -16,6 +16,7 @@ import {
GRAPH_PERSISTENCE_META_KEY, GRAPH_PERSISTENCE_META_KEY,
MODULE_NAME, MODULE_NAME,
} from "../../graph/graph-persistence.js"; } from "../../graph/graph-persistence.js";
import { getSmartTriggerDecision } from "../../maintenance/smart-trigger.js";
import { import {
buildPersistedRecallRecord, buildPersistedRecallRecord,
bumpPersistedRecallGenerationCount, bumpPersistedRecallGenerationCount,
@@ -40,6 +41,10 @@ import {
normalizeStageNoticeLevel, normalizeStageNoticeLevel,
shouldRunRecallForTransaction, shouldRunRecallForTransaction,
} from "../../ui/ui-status.js"; } from "../../ui/ui-status.js";
import {
defaultSettings,
mergePersistedSettings,
} from "../../runtime/settings-defaults.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const indexPath = path.resolve(moduleDir, "../../index.js"); const indexPath = path.resolve(moduleDir, "../../index.js");
@@ -81,7 +86,8 @@ export function createGenerationRecallHarness(options = {}) {
result: null, result: null,
currentGraph: {}, currentGraph: {},
_panelModule: null, _panelModule: null,
defaultSettings: {}, defaultSettings,
mergePersistedSettings,
settings: {}, settings: {},
graphPersistenceState: createGraphPersistenceState(), graphPersistenceState: createGraphPersistenceState(),
extension_settings: { [MODULE_NAME]: {} }, extension_settings: { [MODULE_NAME]: {} },
@@ -121,6 +127,7 @@ export function createGenerationRecallHarness(options = {}) {
[...chat].reverse().find((message) => message?.is_user) || null, [...chat].reverse().find((message) => message?.is_user) || null,
getLastNonSystemChatMessage: (chat = []) => getLastNonSystemChatMessage: (chat = []) =>
[...chat].reverse().find((message) => !message?.is_system) || null, [...chat].reverse().find((message) => !message?.is_system) || null,
getSmartTriggerDecision,
getSendTextareaValue: () => context.__sendTextareaValue, getSendTextareaValue: () => context.__sendTextareaValue,
getRecallUserMessageSourceLabel: (source = "") => source, getRecallUserMessageSourceLabel: (source = "") => source,
getRecallUserMessageSourceLabelController: (source = "") => source, getRecallUserMessageSourceLabelController: (source = "") => source,

View File

@@ -1,35 +1,6 @@
import assert from "node:assert/strict"; 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() { import { getSmartTriggerDecision } from "../maintenance/smart-trigger.js";
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();
const noTrigger = getSmartTriggerDecision( const noTrigger = getSmartTriggerDecision(
[ [
@@ -61,7 +32,7 @@ const customTrigger = getSmartTriggerDecision(
{ triggerPatterns: "真相|背叛", smartTriggerThreshold: 2 }, { triggerPatterns: "真相|背叛", smartTriggerThreshold: 2 },
); );
assert.equal(customTrigger.triggered, true); assert.equal(customTrigger.triggered, true);
assert.ok(customTrigger.reasons.some((r) => r.includes("自定义触发"))); assert.ok(customTrigger.reasons.some((reason) => reason.includes("自定义触发")));
const ignoresProcessedMessages = getSmartTriggerDecision( const ignoresProcessedMessages = getSmartTriggerDecision(
[ [