Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b33c943b69 | |||
| 7f7aced751 | |||
| a867592367 | |||
| e90a2da6cb |
@@ -18,6 +18,7 @@ import { createUserAuth } from './user-auth.mjs';
|
|||||||
import { createLlmProviderService } from './llm-providers.mjs';
|
import { createLlmProviderService } from './llm-providers.mjs';
|
||||||
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
|
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
|
||||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||||
|
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
|
||||||
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
||||||
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
||||||
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
||||||
@@ -100,6 +101,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
|
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
|
||||||
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
|
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
|
||||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||||
|
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root });
|
||||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||||
const adminSystemTestService = createAdminSystemTestService({
|
const adminSystemTestService = createAdminSystemTestService({
|
||||||
pool,
|
pool,
|
||||||
@@ -138,6 +140,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
llmProviderService,
|
llmProviderService,
|
||||||
assetGatewayConfigService,
|
assetGatewayConfigService,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
|
skillRuntimeConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
plazaPosts,
|
plazaPosts,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function plazaRouteError(res, req, error) {
|
|||||||
* @param {object} deps.userAuth
|
* @param {object} deps.userAuth
|
||||||
* @param {object|null} deps.llmProviderService
|
* @param {object|null} deps.llmProviderService
|
||||||
* @param {object|null} deps.memoryV2ConfigService
|
* @param {object|null} deps.memoryV2ConfigService
|
||||||
|
* @param {object|null} deps.skillRuntimeConfigService
|
||||||
* @param {object|null} deps.adminSystemTestService
|
* @param {object|null} deps.adminSystemTestService
|
||||||
* @param {object|null} deps.plazaPosts
|
* @param {object|null} deps.plazaPosts
|
||||||
* @param {object|null} deps.plazaOps
|
* @param {object|null} deps.plazaOps
|
||||||
@@ -44,6 +45,7 @@ export function createAdminApi({
|
|||||||
llmProviderService,
|
llmProviderService,
|
||||||
assetGatewayConfigService,
|
assetGatewayConfigService,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
|
skillRuntimeConfigService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
plazaPosts,
|
plazaPosts,
|
||||||
plazaOps,
|
plazaOps,
|
||||||
@@ -148,6 +150,40 @@ export function createAdminApi({
|
|||||||
return res.json(result);
|
return res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => {
|
||||||
|
if (!skillRuntimeConfigService?.getAdminConfig) {
|
||||||
|
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await skillRuntimeConfigService.getAdminConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSkillRuntimeConfig = async (req, res) => {
|
||||||
|
if (!skillRuntimeConfigService?.updateAdminConfig) {
|
||||||
|
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||||
|
}
|
||||||
|
const result = await skillRuntimeConfigService.updateAdminConfig(req.body?.config ?? req.body ?? {}, {
|
||||||
|
updatedBy: req.currentUser.id,
|
||||||
|
});
|
||||||
|
return res.json(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
adminApi.put('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
|
||||||
|
adminApi.patch('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
|
||||||
|
|
||||||
|
adminApi.get('/skill-runtime/catalog', requireAdmin, async (_req, res) => {
|
||||||
|
if (!skillRuntimeConfigService?.listCatalogSummary) {
|
||||||
|
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json({ catalog: await skillRuntimeConfigService.listCatalogSummary() });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/skill-runtime/runtime', requireAdmin, async (_req, res) => {
|
||||||
|
if (!skillRuntimeConfigService?.getPublicRuntimeConfig) {
|
||||||
|
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
|
||||||
|
});
|
||||||
|
|
||||||
adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => {
|
adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => {
|
||||||
if (!adminSystemTestService?.runSkillValidation) {
|
if (!adminSystemTestService?.runSkillValidation) {
|
||||||
return res.status(503).json({ message: '系统测试服务未启用' });
|
return res.status(503).json({ message: '系统测试服务未启用' });
|
||||||
|
|||||||
+60
-3
@@ -12,9 +12,11 @@ import {
|
|||||||
} from './conversation-transcript-persist.mjs';
|
} from './conversation-transcript-persist.mjs';
|
||||||
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
|
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
|
||||||
import {
|
import {
|
||||||
|
prepareAndDetectSessionDeliverables,
|
||||||
SESSION_FINISHED_STALE_GRACE_MS,
|
SESSION_FINISHED_STALE_GRACE_MS,
|
||||||
tryRecoverRunFromDeliverables,
|
tryRecoverRunFromDeliverables,
|
||||||
} from './agent-run-deliverable-check.mjs';
|
} from './agent-run-deliverable-check.mjs';
|
||||||
|
import { isPageDataIntent } from './chat-skills.mjs';
|
||||||
|
|
||||||
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
|
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
|
||||||
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
|
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
|
||||||
@@ -53,6 +55,17 @@ function serializeMessage(message) {
|
|||||||
return JSON.stringify(message ?? {});
|
return JSON.stringify(message ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractRunMessageText(row) {
|
||||||
|
const message = parseDbJsonColumn(row?.user_message_json, {}) ?? {};
|
||||||
|
if (typeof message.content === 'string') return message.content;
|
||||||
|
if (!Array.isArray(message.content)) return '';
|
||||||
|
return message.content
|
||||||
|
.filter((item) => item?.type === 'text')
|
||||||
|
.map((item) => String(item.text ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function positiveInteger(value, fallback) {
|
function positiveInteger(value, fallback) {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||||
@@ -247,6 +260,8 @@ export function createAgentRunGateway({
|
|||||||
sessionSnapshotService = null,
|
sessionSnapshotService = null,
|
||||||
conversationMemoryService = null,
|
conversationMemoryService = null,
|
||||||
syncUserPagesOnSuccess = null,
|
syncUserPagesOnSuccess = null,
|
||||||
|
observePersonalMemoryOnSuccess = null,
|
||||||
|
isSessionExternallyBusy = null,
|
||||||
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
|
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
|
||||||
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
|
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
|
||||||
maxConcurrentRuns = positiveInteger(
|
maxConcurrentRuns = positiveInteger(
|
||||||
@@ -348,6 +363,15 @@ export function createAgentRunGateway({
|
|||||||
// Prevent multiple concurrent agent runs on the same Goose session, which would
|
// Prevent multiple concurrent agent runs on the same Goose session, which would
|
||||||
// cause replies to arrive out of order and appear garbled in the chat UI.
|
// cause replies to arrive out of order and appear garbled in the chat UI.
|
||||||
if (sessionId && !isDirectChatSessionId(sessionId)) {
|
if (sessionId && !isDirectChatSessionId(sessionId)) {
|
||||||
|
if (typeof isSessionExternallyBusy === 'function' && await isSessionExternallyBusy({
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
})) {
|
||||||
|
const conflict = new Error('该会话正在完成页面交付或自动修复,请稍候再发送');
|
||||||
|
conflict.code = 'SESSION_RUN_CONFLICT';
|
||||||
|
conflict.status = 409;
|
||||||
|
throw conflict;
|
||||||
|
}
|
||||||
const [activeRows] = await pool.query(
|
const [activeRows] = await pool.query(
|
||||||
`SELECT id FROM h5_agent_runs
|
`SELECT id FROM h5_agent_runs
|
||||||
WHERE agent_session_id = ? AND status NOT IN ('succeeded', 'failed')
|
WHERE agent_session_id = ? AND status NOT IN ('succeeded', 'failed')
|
||||||
@@ -765,19 +789,52 @@ export function createAgentRunGateway({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function finalizeSuccessfulRun(runId, row, sessionId) {
|
async function finalizeSuccessfulRun(runId, row, sessionId) {
|
||||||
|
let deliveryResult = null;
|
||||||
|
if (typeof syncUserPagesOnSuccess === 'function') {
|
||||||
|
deliveryResult = await syncUserPagesOnSuccess({
|
||||||
|
userId: row.user_id,
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pageDataErrors = Array.isArray(deliveryResult?.pageDataBind?.errors)
|
||||||
|
? deliveryResult.pageDataBind.errors
|
||||||
|
: [];
|
||||||
|
if (pageDataErrors.length > 0) {
|
||||||
|
const error = new Error(`Page Data 页面绑定失败:${pageDataErrors.map((item) => item?.message ?? item?.code ?? 'unknown').join('; ')}`);
|
||||||
|
error.code = 'PAGE_DATA_DELIVERY_FAILED';
|
||||||
|
error.retryable = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (isPageDataIntent(extractRunMessageText(row))) {
|
||||||
|
const latest = await getRunById(runId);
|
||||||
|
const deliverables = await prepareAndDetectSessionDeliverables({
|
||||||
|
pool,
|
||||||
|
userId: row.user_id,
|
||||||
|
sessionId,
|
||||||
|
runStartedAtMs: latest?.started_at ?? row.started_at ?? null,
|
||||||
|
});
|
||||||
|
if (deliverables.pageCount < 1) {
|
||||||
|
const error = new Error('Page Data 任务未生成可交付页面,不能标记成功');
|
||||||
|
error.code = 'PAGE_DATA_DELIVERABLE_MISSING';
|
||||||
|
error.retryable = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
await markRun(runId, 'succeeded', {
|
await markRun(runId, 'succeeded', {
|
||||||
agent_session_id: sessionId,
|
agent_session_id: sessionId,
|
||||||
completed_at: nowMs(),
|
completed_at: nowMs(),
|
||||||
error_message: null,
|
error_message: null,
|
||||||
});
|
});
|
||||||
if (typeof syncUserPagesOnSuccess === 'function') {
|
if (typeof observePersonalMemoryOnSuccess === 'function') {
|
||||||
await syncUserPagesOnSuccess({
|
await observePersonalMemoryOnSuccess({
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
sessionId,
|
sessionId,
|
||||||
runId,
|
runId,
|
||||||
|
userMessage: parseDbJsonColumn(row.user_message_json, {}),
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[AgentRun] workspace page deliver failed:',
|
'[AgentRun] personal memory shadow observation failed:',
|
||||||
err instanceof Error ? err.message : err,
|
err instanceof Error ? err.message : err,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,14 +10,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ chatState?: string; finishedViaPortalDirectChat?: boolean }} input
|
* @param {{ chatState?: string; finishedViaPortalDirectChat?: boolean; agentRunSucceeded?: boolean }} input
|
||||||
* @returns {'idle' | 'streaming'}
|
* @returns {'idle' | 'streaming'}
|
||||||
*/
|
*/
|
||||||
export function resolvePostAgentRunChatState({
|
export function resolvePostAgentRunChatState({
|
||||||
chatState = 'waiting',
|
chatState = 'waiting',
|
||||||
finishedViaPortalDirectChat = false,
|
finishedViaPortalDirectChat = false,
|
||||||
|
agentRunSucceeded = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (finishedViaPortalDirectChat) return 'idle';
|
if (finishedViaPortalDirectChat || agentRunSucceeded) return 'idle';
|
||||||
if (chatState === 'idle') return 'idle';
|
if (chatState === 'idle') return 'idle';
|
||||||
return 'streaming';
|
return 'streaming';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ test('resolvePostAgentRunChatState prefers portal direct chat completion', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePostAgentRunChatState idles after worker-side agent run success', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolvePostAgentRunChatState({ chatState: 'waiting', agentRunSucceeded: true }),
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolvePostAgentRunChatState({ chatState: 'streaming', agentRunSucceeded: true }),
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => {
|
test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => {
|
||||||
assert.equal(shouldPromoteSessionIdToStreaming('idle'), false);
|
assert.equal(shouldPromoteSessionIdToStreaming('idle'), false);
|
||||||
assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true);
|
assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true);
|
||||||
|
|||||||
+92
-4
@@ -1,6 +1,7 @@
|
|||||||
// Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url).
|
// Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url).
|
||||||
const PUBLISH_SKILL_NAME = 'static-page-publish';
|
const PUBLISH_SKILL_NAME = 'static-page-publish';
|
||||||
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
|
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
|
||||||
|
export const SKILL_ROUTER_V2_ENV = 'TKMIND_SKILL_ROUTER_V2';
|
||||||
|
|
||||||
const WEB_INTENT_PATTERNS = [
|
const WEB_INTENT_PATTERNS = [
|
||||||
/(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|最新消息|发生了什么|最近发生)/u,
|
/(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|最新消息|发生了什么|最近发生)/u,
|
||||||
@@ -37,6 +38,9 @@ const PAGE_DATA_INTENT_PATTERNS = [
|
|||||||
/(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u,
|
/(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u,
|
||||||
/(?:sqlite|数据库|page[\s-]?data)/i,
|
/(?:sqlite|数据库|page[\s-]?data)/i,
|
||||||
/(?:每个用户|各用户).{0,12}(?:提交|记录)/u,
|
/(?:每个用户|各用户).{0,12}(?:提交|记录)/u,
|
||||||
|
/(?:记账|账本|收支|流水|日记|习惯打卡|每日打卡).{0,40}(?:页面|记录|管理|汇总|统计)/u,
|
||||||
|
/(?:管理页面|管理页).{0,30}(?:查看|记录|汇总|统计|明细|删除|修改)/u,
|
||||||
|
/(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function isPageDataIntent(text) {
|
export function isPageDataIntent(text) {
|
||||||
@@ -155,7 +159,7 @@ export function buildChatSkillPrompt(promptKey, skillName) {
|
|||||||
case 'page-data-collect':
|
case 'page-data-collect':
|
||||||
return (
|
return (
|
||||||
`请使用 ${skillName ?? PAGE_DATA_COLLECT_SKILL_NAME} 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集(问卷/报名/台账等),必须使用 Page Data API。` +
|
`请使用 ${skillName ?? PAGE_DATA_COLLECT_SKILL_NAME} 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集(问卷/报名/台账等),必须使用 Page Data API。` +
|
||||||
'先匹配技能内能力分支(默认 A:匿名前台 public insert + 独立后台 password read,口令默认 88888888);展示方案摘要确认后再开工。' +
|
'先匹配技能内能力分支(默认 A:匿名前台 public insert + 独立后台 password read,口令默认 88888888);“帮我做/创建”本身就是创建授权,展示简短方案摘要后必须同一轮继续执行,只有互斥需求或非法口令才追问。' +
|
||||||
'流程:load_skill → private_data_execute 建表 → private_data_register_dataset → write_file/edit_file 写 public/*.html(含 /assets/page-data-client.js)→ private_data_bind_workspace_page 发布并写策略。' +
|
'流程:load_skill → private_data_execute 建表 → private_data_register_dataset → write_file/edit_file 写 public/*.html(含 /assets/page-data-client.js)→ private_data_bind_workspace_page 发布并写策略。' +
|
||||||
'禁止 localStorage / 浏览器本地存储 fallback;禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后只返回 workspaceUrl(/MindSpace/<用户ID>/public/...),禁止给用户 /u/用户名/pages/... 链接,并说明后台入口与口令。'
|
'禁止 localStorage / 浏览器本地存储 fallback;禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后只返回 workspaceUrl(/MindSpace/<用户ID>/public/...),禁止给用户 /u/用户名/pages/... 链接,并说明后台入口与口令。'
|
||||||
);
|
);
|
||||||
@@ -206,9 +210,65 @@ export function filterChatSkills(options, ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAutoChatSkillPrefix(text, grantedSkills = []) {
|
const PROMPT_KEY_BY_SKILL_NAME = new Map(
|
||||||
const trimmed = String(text ?? '').trim();
|
CHAT_SKILL_DEFINITIONS.filter((item) => item.skillName && item.promptKey).map((item) => [
|
||||||
if (!trimmed) return '';
|
item.skillName,
|
||||||
|
item.promptKey,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Opt-in Skill Router v2. Default off unless TKMIND_SKILL_ROUTER_V2=1 or options.skillRouterV2=true. */
|
||||||
|
export function isSkillRouterV2Enabled(explicit) {
|
||||||
|
if (explicit === true) return true;
|
||||||
|
if (explicit === false) return false;
|
||||||
|
const raw =
|
||||||
|
typeof process !== 'undefined' && process?.env ? process.env[SKILL_ROUTER_V2_ENV] : undefined;
|
||||||
|
return /^(1|true|yes)$/i.test(String(raw ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build manifest routes from platform catalog entries that declare trigger.keywords. */
|
||||||
|
export function buildManifestRoutesFromCatalog(catalog = []) {
|
||||||
|
return catalog
|
||||||
|
.filter((item) => Array.isArray(item?.manifest?.trigger?.keywords) && item.manifest.trigger.keywords.length > 0)
|
||||||
|
.map((item) => ({
|
||||||
|
skillName: item.name,
|
||||||
|
keywords: item.manifest.trigger.keywords,
|
||||||
|
priority: Number(item.manifest?.router?.priority ?? 0),
|
||||||
|
promptKey:
|
||||||
|
item.manifest?.router?.promptKey ??
|
||||||
|
PROMPT_KEY_BY_SKILL_NAME.get(item.name) ??
|
||||||
|
null,
|
||||||
|
promptVariant: item.manifest?.router?.promptVariant ?? null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.priority - a.priority || a.skillName.localeCompare(b.skillName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchManifestSkillRoute(text, routes = [], grantedSkills = []) {
|
||||||
|
const normalized = String(text ?? '').trim().toLowerCase();
|
||||||
|
if (!normalized || !Array.isArray(routes) || routes.length === 0) return null;
|
||||||
|
const granted = new Set(grantedSkills);
|
||||||
|
for (const route of routes) {
|
||||||
|
if (!route?.skillName || !granted.has(route.skillName)) continue;
|
||||||
|
const keywords = Array.isArray(route.keywords) ? route.keywords : [];
|
||||||
|
if (keywords.some((keyword) => normalized.includes(String(keyword).trim().toLowerCase()))) {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManifestSkillPrompt(route) {
|
||||||
|
if (!route) return '';
|
||||||
|
if (route.promptVariant === 'web-news') {
|
||||||
|
return buildWebNewsSkillPrompt(route.skillName);
|
||||||
|
}
|
||||||
|
if (route.promptKey) {
|
||||||
|
return buildChatSkillPrompt(route.promptKey, route.skillName);
|
||||||
|
}
|
||||||
|
return `请使用 ${route.skillName} 技能:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegacyAutoChatSkillPrefix(trimmed, grantedSkills) {
|
||||||
if (
|
if (
|
||||||
grantedSkills.includes(PAGE_DATA_COLLECT_SKILL_NAME) &&
|
grantedSkills.includes(PAGE_DATA_COLLECT_SKILL_NAME) &&
|
||||||
isPageDataIntent(trimmed)
|
isPageDataIntent(trimmed)
|
||||||
@@ -226,6 +286,34 @@ export function buildAutoChatSkillPrefix(text, grantedSkills = []) {
|
|||||||
return buildChatSkillPrompt('web', 'web');
|
return buildChatSkillPrompt('web', 'web');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildAutoChatSkillPrefixOptions(publicConfig) {
|
||||||
|
if (!publicConfig?.routerV2Enabled || !publicConfig?.manifestRoutingEnabled) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skillRouterV2: true,
|
||||||
|
manifestRoutes: publicConfig.manifestRoutes ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAutoChatSkillPrefix(text, grantedSkills = [], options = {}) {
|
||||||
|
const trimmed = String(text ?? '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
isSkillRouterV2Enabled(options.skillRouterV2) &&
|
||||||
|
Array.isArray(options.manifestRoutes) &&
|
||||||
|
options.manifestRoutes.length > 0
|
||||||
|
) {
|
||||||
|
const matched = matchManifestSkillRoute(trimmed, options.manifestRoutes, grantedSkills);
|
||||||
|
if (matched) {
|
||||||
|
return buildManifestSkillPrompt(matched);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildLegacyAutoChatSkillPrefix(trimmed, grantedSkills);
|
||||||
|
}
|
||||||
|
|
||||||
export function stripKnownChatSkillPrompt(text) {
|
export function stripKnownChatSkillPrompt(text) {
|
||||||
let next = String(text ?? '').trimStart();
|
let next = String(text ?? '').trimStart();
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
|
|||||||
@@ -22,6 +22,59 @@ const FIELD_SPECS = [
|
|||||||
{ env: 'MEMIND_CHAT_ROUTER_TIMEOUT_MS', group: 'chatIntentRouter', field: 'timeoutMs', type: 'number' },
|
{ env: 'MEMIND_CHAT_ROUTER_TIMEOUT_MS', group: 'chatIntentRouter', field: 'timeoutMs', type: 'number' },
|
||||||
{ env: 'MEMIND_CHAT_ROUTER_FALLBACK_ROUTE', group: 'chatIntentRouter', field: 'fallbackRoute', type: 'string' },
|
{ env: 'MEMIND_CHAT_ROUTER_FALLBACK_ROUTE', group: 'chatIntentRouter', field: 'fallbackRoute', type: 'string' },
|
||||||
|
|
||||||
|
{ env: 'MEMORY_CANDIDATE_ENABLED', group: 'candidateMemory', field: 'enabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_CANDIDATE_MODE', group: 'candidateMemory', field: 'mode', type: 'string' },
|
||||||
|
{ env: 'MEMORY_CANDIDATE_MIN_IMPORTANCE', group: 'candidateMemory', field: 'minImportance', type: 'number' },
|
||||||
|
{ env: 'MEMORY_CANDIDATE_MIN_CONFIDENCE', group: 'candidateMemory', field: 'minConfidence', type: 'number' },
|
||||||
|
{ env: 'MEMORY_CANDIDATE_MAX_PENDING', group: 'candidateMemory', field: 'maxPending', type: 'number' },
|
||||||
|
{ env: 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', group: 'candidateMemory', field: 'persistenceEnabled', type: 'boolean' },
|
||||||
|
|
||||||
|
{ env: 'MEMORY_POLICY_ENABLED', group: 'policy', field: 'enabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_POLICY_SAVE_EXPLICIT', group: 'policy', field: 'saveExplicit', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_POLICY_REJECT_SENSITIVE', group: 'policy', field: 'rejectSensitive', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_POLICY_REQUIRE_EVIDENCE', group: 'policy', field: 'requireEvidence', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_POLICY_RETENTION_DAYS', group: 'policy', field: 'retentionDays', type: 'number' },
|
||||||
|
|
||||||
|
{ env: 'MEMORY_RETRIEVER_ENABLED', group: 'retriever', field: 'enabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_RETRIEVER_EPISODIC_ENABLED', group: 'retriever', field: 'episodicEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_RETRIEVER_SEMANTIC_ENABLED', group: 'retriever', field: 'semanticEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_RETRIEVER_PREFERENCE_ENABLED', group: 'retriever', field: 'preferenceEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_RETRIEVER_GOAL_ENABLED', group: 'retriever', field: 'goalEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_RETRIEVER_LIMIT', group: 'retriever', field: 'limit', type: 'number' },
|
||||||
|
{ env: 'MEMORY_RETRIEVER_TOKEN_BUDGET', group: 'retriever', field: 'tokenBudget', type: 'number' },
|
||||||
|
{ env: 'MEMORY_RETRIEVER_TIMEOUT_MS', group: 'retriever', field: 'timeoutMs', type: 'number' },
|
||||||
|
|
||||||
|
{ env: 'MEMORY_LIFECYCLE_ENABLED', group: 'lifecycle', field: 'enabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_LIFECYCLE_DEDUPE_ENABLED', group: 'lifecycle', field: 'dedupeEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_LIFECYCLE_CONFLICT_REVIEW', group: 'lifecycle', field: 'conflictReview', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_LIFECYCLE_DECAY_ENABLED', group: 'lifecycle', field: 'decayEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_LIFECYCLE_FORGETTING_ENABLED', group: 'lifecycle', field: 'forgettingEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_LIFECYCLE_COMPACT_INTERVAL_HOURS', group: 'lifecycle', field: 'compactIntervalHours', type: 'number' },
|
||||||
|
|
||||||
|
{ env: 'MEMORY_PERSONA_ENABLED', group: 'persona', field: 'enabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_PERSONA_PROVIDER', group: 'persona', field: 'provider', type: 'string' },
|
||||||
|
{ env: 'MEMORY_PERSONA_SHADOW_MODE', group: 'persona', field: 'shadowMode', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_PERSONA_MAX_TOKENS', group: 'persona', field: 'maxTokens', type: 'number' },
|
||||||
|
{ env: 'MEMORY_PERSONA_CACHE_TTL_SECONDS', group: 'persona', field: 'cacheTtlSeconds', type: 'number' },
|
||||||
|
|
||||||
|
{ env: 'MEMORY_GRAPH_ENABLED', group: 'graph', field: 'enabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_GRAPH_PROVIDER', group: 'graph', field: 'provider', type: 'string' },
|
||||||
|
{ env: 'MEMORY_GRAPH_MAX_DEPTH', group: 'graph', field: 'maxDepth', type: 'number' },
|
||||||
|
{ env: 'MEMORY_GRAPH_RELATION_LIMIT', group: 'graph', field: 'relationLimit', type: 'number' },
|
||||||
|
|
||||||
|
{ env: 'MEMORY_USER_MANAGEMENT_ENABLED', group: 'userMemory', field: 'enabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_USER_REVIEW_ENABLED', group: 'userMemory', field: 'reviewEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_USER_CORRECTION_ENABLED', group: 'userMemory', field: 'correctionEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_USER_PIN_ENABLED', group: 'userMemory', field: 'pinEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_USER_FORGET_ENABLED', group: 'userMemory', field: 'forgetEnabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_USER_DELETE_PROPAGATION', group: 'userMemory', field: 'deletePropagation', type: 'boolean' },
|
||||||
|
|
||||||
|
{ env: 'MEMORY_PLUGIN_HEALTH_ENABLED', group: 'pluginHealth', field: 'enabled', type: 'boolean' },
|
||||||
|
{ env: 'MEMORY_PLUGIN_HEALTH_INTERVAL_SECONDS', group: 'pluginHealth', field: 'intervalSeconds', type: 'number' },
|
||||||
|
{ env: 'MEMORY_PLUGIN_HEALTH_TIMEOUT_MS', group: 'pluginHealth', field: 'timeoutMs', type: 'number' },
|
||||||
|
{ env: 'MEMORY_PLUGIN_HEALTH_FAILURE_THRESHOLD', group: 'pluginHealth', field: 'failureThreshold', type: 'number' },
|
||||||
|
{ env: 'MEMORY_PLUGIN_HEALTH_AUTO_FALLBACK', group: 'pluginHealth', field: 'autoFallback', type: 'boolean' },
|
||||||
|
|
||||||
{ env: 'MEMORY_PGVECTOR_ENABLED', group: 'pgvector', field: 'enabled', type: 'boolean' },
|
{ env: 'MEMORY_PGVECTOR_ENABLED', group: 'pgvector', field: 'enabled', type: 'boolean' },
|
||||||
{ env: 'MEMORY_PGVECTOR_DATABASE_URL', group: 'pgvector', field: 'databaseUrl', type: 'secret' },
|
{ env: 'MEMORY_PGVECTOR_DATABASE_URL', group: 'pgvector', field: 'databaseUrl', type: 'secret' },
|
||||||
{ env: 'MEMORY_PGVECTOR_TABLE', group: 'pgvector', field: 'table', type: 'string' },
|
{ env: 'MEMORY_PGVECTOR_TABLE', group: 'pgvector', field: 'table', type: 'string' },
|
||||||
@@ -95,6 +148,14 @@ const FIELD_SPECS = [
|
|||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
'global',
|
'global',
|
||||||
'chatIntentRouter',
|
'chatIntentRouter',
|
||||||
|
'candidateMemory',
|
||||||
|
'policy',
|
||||||
|
'retriever',
|
||||||
|
'lifecycle',
|
||||||
|
'persona',
|
||||||
|
'graph',
|
||||||
|
'userMemory',
|
||||||
|
'pluginHealth',
|
||||||
'pgvector',
|
'pgvector',
|
||||||
'qdrant',
|
'qdrant',
|
||||||
'weaviate',
|
'weaviate',
|
||||||
|
|||||||
@@ -81,6 +81,36 @@ test('memory v2 admin config service persists non-secret and secret patches', as
|
|||||||
url: 'http://127.0.0.1:6333',
|
url: 'http://127.0.0.1:6333',
|
||||||
apiKey: 'secret-qdrant',
|
apiKey: 'secret-qdrant',
|
||||||
},
|
},
|
||||||
|
candidateMemory: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'shadow',
|
||||||
|
minImportance: '0.75',
|
||||||
|
minConfidence: '0.85',
|
||||||
|
maxPending: '500',
|
||||||
|
persistenceEnabled: true,
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
enabled: true,
|
||||||
|
saveExplicit: true,
|
||||||
|
rejectSensitive: true,
|
||||||
|
requireEvidence: true,
|
||||||
|
retentionDays: '365',
|
||||||
|
},
|
||||||
|
retriever: {
|
||||||
|
enabled: true,
|
||||||
|
episodicEnabled: true,
|
||||||
|
semanticEnabled: true,
|
||||||
|
preferenceEnabled: true,
|
||||||
|
goalEnabled: true,
|
||||||
|
limit: '12',
|
||||||
|
tokenBudget: '1800',
|
||||||
|
timeoutMs: '1200',
|
||||||
|
},
|
||||||
|
lifecycle: { enabled: true, dedupeEnabled: true, conflictReview: true },
|
||||||
|
persona: { enabled: true, provider: 'ai-mind', shadowMode: true },
|
||||||
|
graph: { enabled: true, provider: 'postgres', maxDepth: '2' },
|
||||||
|
userMemory: { enabled: true, reviewEnabled: true, forgetEnabled: true, deletePropagation: true },
|
||||||
|
pluginHealth: { enabled: true, intervalSeconds: '60', timeoutMs: '1500', failureThreshold: '3', autoFallback: true },
|
||||||
}, { updatedBy: 'admin-1' });
|
}, { updatedBy: 'admin-1' });
|
||||||
|
|
||||||
assert.equal(updated.config.global.enabled, true);
|
assert.equal(updated.config.global.enabled, true);
|
||||||
@@ -91,6 +121,15 @@ test('memory v2 admin config service persists non-secret and secret patches', as
|
|||||||
assert.equal(updated.config.qdrant.enabled, true);
|
assert.equal(updated.config.qdrant.enabled, true);
|
||||||
assert.equal(updated.config.qdrant.url, 'http://127.0.0.1:6333');
|
assert.equal(updated.config.qdrant.url, 'http://127.0.0.1:6333');
|
||||||
assert.equal(updated.config.qdrant.apiKeyConfigured, true);
|
assert.equal(updated.config.qdrant.apiKeyConfigured, true);
|
||||||
|
assert.equal(updated.config.candidateMemory.mode, 'shadow');
|
||||||
|
assert.equal(updated.config.candidateMemory.persistenceEnabled, true);
|
||||||
|
assert.equal(updated.config.policy.requireEvidence, true);
|
||||||
|
assert.equal(updated.config.retriever.tokenBudget, '1800');
|
||||||
|
assert.equal(updated.config.lifecycle.dedupeEnabled, true);
|
||||||
|
assert.equal(updated.config.persona.provider, 'ai-mind');
|
||||||
|
assert.equal(updated.config.graph.provider, 'postgres');
|
||||||
|
assert.equal(updated.config.userMemory.deletePropagation, true);
|
||||||
|
assert.equal(updated.config.pluginHealth.autoFallback, true);
|
||||||
assert.equal(updated.updatedBy, 'admin-1');
|
assert.equal(updated.updatedBy, 'admin-1');
|
||||||
|
|
||||||
const runtimeState = await service.getRuntimeState();
|
const runtimeState = await service.getRuntimeState();
|
||||||
@@ -107,6 +146,16 @@ test('memory v2 admin config service persists non-secret and secret patches', as
|
|||||||
assert.equal(runtimeState.overrides.MEMORY_QDRANT_ENABLED, '1');
|
assert.equal(runtimeState.overrides.MEMORY_QDRANT_ENABLED, '1');
|
||||||
assert.equal(runtimeState.overrides.MEMORY_QDRANT_URL, 'http://127.0.0.1:6333');
|
assert.equal(runtimeState.overrides.MEMORY_QDRANT_URL, 'http://127.0.0.1:6333');
|
||||||
assert.equal(runtimeState.overrides.MEMORY_QDRANT_API_KEY, 'secret-qdrant');
|
assert.equal(runtimeState.overrides.MEMORY_QDRANT_API_KEY, 'secret-qdrant');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_CANDIDATE_ENABLED, '1');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_CANDIDATE_MODE, 'shadow');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_CANDIDATE_PERSISTENCE_ENABLED, '1');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_POLICY_REQUIRE_EVIDENCE, '1');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_RETRIEVER_TOKEN_BUDGET, '1800');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_LIFECYCLE_DEDUPE_ENABLED, '1');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_PERSONA_PROVIDER, 'ai-mind');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_GRAPH_PROVIDER, 'postgres');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_USER_DELETE_PROPAGATION, '1');
|
||||||
|
assert.equal(runtimeState.overrides.MEMORY_PLUGIN_HEALTH_AUTO_FALLBACK, '1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('memory v2 admin config internals flatten booleans and numbers consistently', () => {
|
test('memory v2 admin config internals flatten booleans and numbers consistently', () => {
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { deriveUserFacingText } from './conversation-display.mjs';
|
||||||
|
|
||||||
|
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||||
|
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
|
||||||
|
const SECRET_PATTERNS = [
|
||||||
|
/\b(?:api[_-]?key|access[_-]?token|secret|password|passwd)\b\s*[:=]/i,
|
||||||
|
/\bsk-[a-z0-9_-]{12,}\b/i,
|
||||||
|
/-----BEGIN [A-Z ]+PRIVATE KEY-----/,
|
||||||
|
/(?:密码|口令|密钥|令牌)\s*[::=]\s*\S+/i,
|
||||||
|
];
|
||||||
|
const TRIVIAL_PATTERNS = [
|
||||||
|
/^(?:你好|您好|谢谢|好的|可以|收到|再见|hi|hello|thanks)[!!。.\s]*$/i,
|
||||||
|
/^(?:查一下|搜一下|看看|继续|下一步)[。.!!??\s]*$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function readFlag(env, key, fallback = false) {
|
||||||
|
const raw = env?.[key];
|
||||||
|
if (raw == null || raw === '') return fallback;
|
||||||
|
const normalized = String(raw).trim().toLowerCase();
|
||||||
|
if (TRUE_VALUES.has(normalized)) return true;
|
||||||
|
if (FALSE_VALUES.has(normalized)) return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(env, key, fallback, { min = -Infinity, max = Infinity } = {}) {
|
||||||
|
const value = Number(env?.[key]);
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value, maxLength = 2000) {
|
||||||
|
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > maxLength ? text.slice(0, maxLength) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageText(message) {
|
||||||
|
if (typeof message === 'string') return normalizeText(message);
|
||||||
|
if (!message || typeof message !== 'object') return '';
|
||||||
|
const displayText = message.displayText
|
||||||
|
?? message.display_text
|
||||||
|
?? message.metadata?.displayText
|
||||||
|
?? message.metadata?.display_text;
|
||||||
|
if (typeof displayText === 'string' && displayText.trim()) {
|
||||||
|
return normalizeText(displayText);
|
||||||
|
}
|
||||||
|
const direct = message.text ?? message.content ?? message.message ?? '';
|
||||||
|
if (typeof direct === 'string') return normalizeText(deriveUserFacingText(direct));
|
||||||
|
if (Array.isArray(direct)) {
|
||||||
|
return normalizeText(deriveUserFacingText(
|
||||||
|
direct.map((item) => item?.text ?? item?.content ?? '').join('\n'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableHash(value) {
|
||||||
|
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify(text) {
|
||||||
|
const rules = [
|
||||||
|
{ type: 'episodic', importance: 0.95, confidence: 0.98, pattern: /(?:请记住|记住|以后要|从现在起)/i, reason: 'explicit_memory_request' },
|
||||||
|
{ type: 'episodic', importance: 0.9, confidence: 0.9, pattern: /(?:决定|确定|统一采用|必须|不允许|禁止|最终选择)/i, reason: 'decision_signal' },
|
||||||
|
{ type: 'preference', importance: 0.8, confidence: 0.88, pattern: /(?:我喜欢|我偏好|我不喜欢|我的习惯|倾向于|更希望)/i, reason: 'preference_signal' },
|
||||||
|
{ type: 'goal', importance: 0.85, confidence: 0.86, pattern: /(?:长期目标|当前目标|目标是|计划要|正在建设|准备实现|希望长期)/i, reason: 'goal_signal' },
|
||||||
|
{ type: 'semantic', importance: 0.75, confidence: 0.82, pattern: /(?:技术栈|架构原则|项目使用|系统采用|仓库位于|运行在)/i, reason: 'stable_fact_signal' },
|
||||||
|
];
|
||||||
|
return rules.find((rule) => rule.pattern.test(text)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANDIDATE_MODES = new Set(['off', 'shadow', 'canary', 'active']);
|
||||||
|
|
||||||
|
function normalizeCandidateMode(value, fallback = 'shadow') {
|
||||||
|
const normalized = normalizeText(value, 20).toLowerCase();
|
||||||
|
return CANDIDATE_MODES.has(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoAcceptCandidate(candidate, config) {
|
||||||
|
if (!candidate || !config?.enabled) return false;
|
||||||
|
const mode = normalizeCandidateMode(config.requestedMode, 'shadow');
|
||||||
|
if (mode === 'shadow' || mode === 'off') return false;
|
||||||
|
if (mode === 'active') return true;
|
||||||
|
if (candidate.policyReason === 'explicit_memory_request') return true;
|
||||||
|
return Number(candidate.confidence) >= Math.max(Number(config.minConfidence) || 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePersonalShadowConfig(env = process.env) {
|
||||||
|
const candidateEnabled = readFlag(env, 'MEMORY_CANDIDATE_ENABLED', false);
|
||||||
|
const requestedMode = normalizeCandidateMode(
|
||||||
|
env?.MEMORY_CANDIDATE_MODE || (candidateEnabled ? 'active' : 'off'),
|
||||||
|
candidateEnabled ? 'active' : 'off',
|
||||||
|
);
|
||||||
|
const enabled = candidateEnabled && requestedMode !== 'off';
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
requestedMode,
|
||||||
|
effectiveMode: enabled ? requestedMode : 'off',
|
||||||
|
autoReviewEnabled: enabled && requestedMode !== 'shadow',
|
||||||
|
minImportance: readNumber(env, 'MEMORY_CANDIDATE_MIN_IMPORTANCE', 0.7, { min: 0, max: 1 }),
|
||||||
|
minConfidence: readNumber(env, 'MEMORY_CANDIDATE_MIN_CONFIDENCE', 0.8, { min: 0, max: 1 }),
|
||||||
|
maxPending: Math.round(readNumber(env, 'MEMORY_CANDIDATE_MAX_PENDING', 500, { min: 1, max: 5000 })),
|
||||||
|
policyEnabled: readFlag(env, 'MEMORY_POLICY_ENABLED', true),
|
||||||
|
saveExplicit: readFlag(env, 'MEMORY_POLICY_SAVE_EXPLICIT', true),
|
||||||
|
rejectSensitive: readFlag(env, 'MEMORY_POLICY_REJECT_SENSITIVE', true),
|
||||||
|
requireEvidence: readFlag(env, 'MEMORY_POLICY_REQUIRE_EVIDENCE', true),
|
||||||
|
healthEnabled: readFlag(env, 'MEMORY_PLUGIN_HEALTH_ENABLED', true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPersonalMemoryShadowPipeline({ env = process.env, now = () => Date.now(), store = null } = {}) {
|
||||||
|
const config = resolvePersonalShadowConfig(env);
|
||||||
|
const candidates = [];
|
||||||
|
const candidateHashes = new Set();
|
||||||
|
const metrics = {
|
||||||
|
runs: 0,
|
||||||
|
observedMessages: 0,
|
||||||
|
accepted: 0,
|
||||||
|
autoReviewed: 0,
|
||||||
|
pendingReview: 0,
|
||||||
|
rejected: 0,
|
||||||
|
deduped: 0,
|
||||||
|
errors: 0,
|
||||||
|
lastRunAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reject(reason) {
|
||||||
|
metrics.rejected += 1;
|
||||||
|
return { accepted: false, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate({ userId, sessionId, message, index }) {
|
||||||
|
const text = messageText(message);
|
||||||
|
if (!text || text.length < 8) return reject('too_short');
|
||||||
|
if (TRIVIAL_PATTERNS.some((pattern) => pattern.test(text))) return reject('trivial');
|
||||||
|
if (config.rejectSensitive && SECRET_PATTERNS.some((pattern) => pattern.test(text))) {
|
||||||
|
return reject('sensitive_content');
|
||||||
|
}
|
||||||
|
const classification = classify(text);
|
||||||
|
if (!classification) return reject('no_durable_signal');
|
||||||
|
if (classification.reason === 'explicit_memory_request' && !config.saveExplicit) {
|
||||||
|
return reject('explicit_memory_disabled');
|
||||||
|
}
|
||||||
|
if (config.policyEnabled && classification.importance < config.minImportance) {
|
||||||
|
return reject('below_importance_threshold');
|
||||||
|
}
|
||||||
|
if (config.policyEnabled && classification.confidence < config.minConfidence) {
|
||||||
|
return reject('below_confidence_threshold');
|
||||||
|
}
|
||||||
|
const contentHash = stableHash(`${userId}\n${classification.type}\n${text.toLowerCase()}`);
|
||||||
|
if (candidateHashes.has(contentHash)) {
|
||||||
|
metrics.deduped += 1;
|
||||||
|
return { accepted: false, reason: 'duplicate' };
|
||||||
|
}
|
||||||
|
const capturedAt = now();
|
||||||
|
const evidence = {
|
||||||
|
sourceType: 'message',
|
||||||
|
sourceId: `${sessionId || 'unknown'}:${index}`,
|
||||||
|
evidenceHash: stableHash(`${sessionId || ''}\n${index}\n${text}`),
|
||||||
|
capturedAt,
|
||||||
|
};
|
||||||
|
if (config.requireEvidence && !evidence.sourceId) return reject('evidence_required');
|
||||||
|
const candidate = {
|
||||||
|
id: `pmc_${contentHash.slice(0, 24)}`,
|
||||||
|
userId: String(userId),
|
||||||
|
sessionId: sessionId ? String(sessionId) : null,
|
||||||
|
memoryType: classification.type,
|
||||||
|
content: text,
|
||||||
|
importance: classification.importance,
|
||||||
|
confidence: classification.confidence,
|
||||||
|
status: 'candidate',
|
||||||
|
policyReason: classification.reason,
|
||||||
|
evidence,
|
||||||
|
createdAt: capturedAt,
|
||||||
|
};
|
||||||
|
candidateHashes.add(contentHash);
|
||||||
|
candidates.push(candidate);
|
||||||
|
while (candidates.length > config.maxPending) {
|
||||||
|
const removed = candidates.shift();
|
||||||
|
if (removed) candidateHashes.delete(stableHash(`${removed.userId}\n${removed.memoryType}\n${removed.content.toLowerCase()}`));
|
||||||
|
}
|
||||||
|
metrics.accepted += 1;
|
||||||
|
return { accepted: true, candidate };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function observeWrite({ userId, sessionId, messages = [] } = {}) {
|
||||||
|
if (!config.enabled) return { enabled: false, skipped: true, reason: 'disabled' };
|
||||||
|
metrics.runs += 1;
|
||||||
|
metrics.lastRunAt = now();
|
||||||
|
if (!userId || !Array.isArray(messages)) {
|
||||||
|
return { enabled: true, skipped: true, reason: 'invalid_input' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = [];
|
||||||
|
for (let index = 0; index < messages.length; index += 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
const role = normalizeText(message?.role ?? message?.sender ?? '', 30).toLowerCase();
|
||||||
|
if (role && !['user', 'human'].includes(role)) continue;
|
||||||
|
metrics.observedMessages += 1;
|
||||||
|
const result = evaluate({ userId, sessionId, message, index });
|
||||||
|
if (result.accepted && store?.saveCandidate) {
|
||||||
|
const autoAccept = shouldAutoAcceptCandidate(result.candidate, config);
|
||||||
|
const persisted = await store.saveCandidate(result.candidate, { autoAccept });
|
||||||
|
if (persisted?.inserted === false) {
|
||||||
|
metrics.deduped += 1;
|
||||||
|
} else if (autoAccept) {
|
||||||
|
result.candidate.status = 'accepted';
|
||||||
|
metrics.autoReviewed += 1;
|
||||||
|
} else {
|
||||||
|
metrics.pendingReview += 1;
|
||||||
|
}
|
||||||
|
} else if (result.accepted) {
|
||||||
|
const autoAccept = shouldAutoAcceptCandidate(result.candidate, config);
|
||||||
|
result.candidate.status = autoAccept ? 'accepted' : 'candidate';
|
||||||
|
if (autoAccept) metrics.autoReviewed += 1;
|
||||||
|
else metrics.pendingReview += 1;
|
||||||
|
}
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
skipped: false,
|
||||||
|
mode: config.effectiveMode,
|
||||||
|
autoReviewEnabled: config.autoReviewEnabled,
|
||||||
|
observed: results.length,
|
||||||
|
accepted: results.filter((result) => result.accepted).length,
|
||||||
|
autoReviewed: results.filter((result) => result.accepted && result.candidate?.status === 'accepted').length,
|
||||||
|
pendingReview: results.filter((result) => result.accepted && result.candidate?.status === 'candidate').length,
|
||||||
|
rejected: results.filter((result) => !result.accepted).length,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
metrics.errors += 1;
|
||||||
|
metrics.lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
return { enabled: true, skipped: true, reason: 'pipeline_failure' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
return {
|
||||||
|
enabled: config.enabled,
|
||||||
|
requestedMode: config.requestedMode,
|
||||||
|
effectiveMode: config.effectiveMode,
|
||||||
|
phase: config.autoReviewEnabled ? 'auto-review-v1' : 'shadow-candidate-v1',
|
||||||
|
autoReviewEnabled: config.autoReviewEnabled,
|
||||||
|
injectionEnabled: false,
|
||||||
|
persistence: store?.saveCandidate ? 'mysql' : 'bounded-memory',
|
||||||
|
pendingCandidates: candidates.length,
|
||||||
|
health: {
|
||||||
|
enabled: config.healthEnabled,
|
||||||
|
state: metrics.errors > 0 ? 'degraded' : 'healthy',
|
||||||
|
...metrics,
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
enabled: config.policyEnabled,
|
||||||
|
minImportance: config.minImportance,
|
||||||
|
minConfidence: config.minConfidence,
|
||||||
|
rejectSensitive: config.rejectSensitive,
|
||||||
|
requireEvidence: config.requireEvidence,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCandidates({ limit = 50 } = {}) {
|
||||||
|
return candidates.slice(-Math.max(1, Math.min(200, Number(limit) || 50))).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config, observeWrite, getStatus, listCandidates };
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createPersonalMemoryShadowPipeline,
|
||||||
|
resolvePersonalShadowConfig,
|
||||||
|
shouldAutoAcceptCandidate,
|
||||||
|
} from './memory-v2-personal-shadow.mjs';
|
||||||
|
import { createMemoryV2 } from './memory-v2.mjs';
|
||||||
|
|
||||||
|
test('personal memory shadow config maps active mode to auto review', () => {
|
||||||
|
const config = resolvePersonalShadowConfig({
|
||||||
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
|
MEMORY_CANDIDATE_MODE: 'active',
|
||||||
|
});
|
||||||
|
assert.equal(config.enabled, true);
|
||||||
|
assert.equal(config.requestedMode, 'active');
|
||||||
|
assert.equal(config.effectiveMode, 'active');
|
||||||
|
assert.equal(config.autoReviewEnabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('personal memory shadow config keeps shadow mode for manual review', () => {
|
||||||
|
const config = resolvePersonalShadowConfig({
|
||||||
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
|
MEMORY_CANDIDATE_MODE: 'shadow',
|
||||||
|
});
|
||||||
|
assert.equal(config.effectiveMode, 'shadow');
|
||||||
|
assert.equal(config.autoReviewEnabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldAutoAcceptCandidate auto accepts explicit requests in canary mode', () => {
|
||||||
|
const config = resolvePersonalShadowConfig({
|
||||||
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
|
MEMORY_CANDIDATE_MODE: 'canary',
|
||||||
|
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
|
||||||
|
});
|
||||||
|
assert.equal(shouldAutoAcceptCandidate({
|
||||||
|
policyReason: 'explicit_memory_request',
|
||||||
|
confidence: 0.98,
|
||||||
|
}, config), true);
|
||||||
|
assert.equal(shouldAutoAcceptCandidate({
|
||||||
|
policyReason: 'stable_fact_signal',
|
||||||
|
confidence: 0.82,
|
||||||
|
}, config), false);
|
||||||
|
assert.equal(shouldAutoAcceptCandidate({
|
||||||
|
policyReason: 'decision_signal',
|
||||||
|
confidence: 0.9,
|
||||||
|
}, config), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shadow pipeline auto accepts durable candidates in active mode', async () => {
|
||||||
|
const saved = [];
|
||||||
|
const pipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env: {
|
||||||
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
|
MEMORY_CANDIDATE_MODE: 'active',
|
||||||
|
MEMORY_POLICY_ENABLED: '1',
|
||||||
|
},
|
||||||
|
store: {
|
||||||
|
async saveCandidate(candidate, options = {}) {
|
||||||
|
saved.push({ candidate, options });
|
||||||
|
return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await pipeline.observeWrite({
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [{ role: 'user', text: '我决定所有生产发布必须从完整 main 分支打包。' }],
|
||||||
|
});
|
||||||
|
assert.equal(result.autoReviewed, 1);
|
||||||
|
assert.equal(result.pendingReview, 0);
|
||||||
|
assert.equal(saved.length, 1);
|
||||||
|
assert.equal(saved[0].options.autoAccept, true);
|
||||||
|
assert.equal(pipeline.getStatus().autoReviewEnabled, true);
|
||||||
|
assert.equal(pipeline.getStatus().phase, 'auto-review-v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shadow pipeline keeps low-confidence canary candidates pending review', async () => {
|
||||||
|
const saved = [];
|
||||||
|
const pipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env: {
|
||||||
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
|
MEMORY_CANDIDATE_MODE: 'canary',
|
||||||
|
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
|
||||||
|
},
|
||||||
|
store: {
|
||||||
|
async saveCandidate(candidate, options = {}) {
|
||||||
|
saved.push({ candidate, options });
|
||||||
|
return { inserted: true, status: options.autoAccept ? 'accepted' : 'candidate' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pipeline.observeWrite({
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [{ role: 'user', text: '项目使用 pgvector 作为向量存储。' }],
|
||||||
|
});
|
||||||
|
assert.equal(saved.length, 1);
|
||||||
|
assert.equal(saved[0].options.autoAccept, false);
|
||||||
|
assert.equal(pipeline.getStatus().health.pendingReview, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shadow pipeline extracts durable candidates with evidence and deduplicates', async () => {
|
||||||
|
let clock = 1000;
|
||||||
|
const pipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env: {
|
||||||
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
|
MEMORY_CANDIDATE_MODE: 'shadow',
|
||||||
|
MEMORY_POLICY_ENABLED: '1',
|
||||||
|
MEMORY_POLICY_REQUIRE_EVIDENCE: '1',
|
||||||
|
MEMORY_CANDIDATE_MIN_IMPORTANCE: '0.7',
|
||||||
|
MEMORY_CANDIDATE_MIN_CONFIDENCE: '0.8',
|
||||||
|
},
|
||||||
|
now: () => {
|
||||||
|
clock += 1;
|
||||||
|
return clock;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', text: '我决定所有生产发布必须从完整 main 分支打包。' },
|
||||||
|
{ role: 'assistant', text: '收到。' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const first = await pipeline.observeWrite(input);
|
||||||
|
const second = await pipeline.observeWrite(input);
|
||||||
|
|
||||||
|
assert.equal(first.accepted, 1);
|
||||||
|
assert.equal(second.accepted, 0);
|
||||||
|
assert.equal(pipeline.listCandidates().length, 1);
|
||||||
|
assert.equal(pipeline.listCandidates()[0].memoryType, 'episodic');
|
||||||
|
assert.equal(pipeline.listCandidates()[0].evidence.sourceId, 's1:0');
|
||||||
|
assert.equal(pipeline.getStatus().health.deduped, 1);
|
||||||
|
assert.equal(pipeline.getStatus().injectionEnabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shadow policy rejects trivial and sensitive messages', async () => {
|
||||||
|
const pipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env: {
|
||||||
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
|
MEMORY_CANDIDATE_MODE: 'shadow',
|
||||||
|
MEMORY_POLICY_REJECT_SENSITIVE: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await pipeline.observeWrite({
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', text: '谢谢' },
|
||||||
|
{ role: 'user', text: '请记住 API_KEY=sk-this-is-a-secret-token' },
|
||||||
|
{ role: 'user', text: '今天天气怎么样?' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(result.accepted, 0);
|
||||||
|
assert.equal(result.rejected, 3);
|
||||||
|
assert.equal(pipeline.listCandidates().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shadow pipeline prefers user-facing displayText over internal prompt content', async () => {
|
||||||
|
const pipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env: { MEMORY_CANDIDATE_ENABLED: '1', MEMORY_CANDIDATE_MODE: 'shadow' },
|
||||||
|
});
|
||||||
|
await pipeline.observeWrite({
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '[用户身份] 内部前缀\n我决定先测试再发布。' }],
|
||||||
|
metadata: { displayText: '我决定先测试再发布。' },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(pipeline.listCandidates().length, 1);
|
||||||
|
assert.equal(pipeline.listCandidates()[0].content, '我决定先测试再发布。');
|
||||||
|
assert.doesNotMatch(pipeline.listCandidates()[0].content, /用户身份/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shadow pipeline strips protected user identity prefix when displayText is absent', async () => {
|
||||||
|
const pipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env: { MEMORY_CANDIDATE_ENABLED: '1', MEMORY_CANDIDATE_MODE: 'shadow' },
|
||||||
|
});
|
||||||
|
await pipeline.observeWrite({
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: '[用户身份]\n- 当前登录用户称呼:John\n- 内部提示\n\n我决定先测试再发布。',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(pipeline.listCandidates().length, 1);
|
||||||
|
assert.equal(pipeline.listCandidates()[0].content, '我决定先测试再发布。');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shadow candidate pool remains bounded', async () => {
|
||||||
|
const pipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env: {
|
||||||
|
MEMORY_CANDIDATE_ENABLED: '1',
|
||||||
|
MEMORY_CANDIDATE_MODE: 'shadow',
|
||||||
|
MEMORY_CANDIDATE_MAX_PENDING: '2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pipeline.observeWrite({
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', text: '我决定项目一使用完整 main 发布。' },
|
||||||
|
{ role: 'user', text: '我决定项目二使用完整 main 发布。' },
|
||||||
|
{ role: 'user', text: '我决定项目三使用完整 main 发布。' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(pipeline.listCandidates().length, 2);
|
||||||
|
assert.match(pipeline.listCandidates()[0].content, /项目三/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shadow pipeline persists accepted candidates when a store is configured', async () => {
|
||||||
|
const saved = [];
|
||||||
|
const pipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env: { MEMORY_CANDIDATE_ENABLED: '1', MEMORY_CANDIDATE_MODE: 'shadow' },
|
||||||
|
store: { async saveCandidate(candidate) { saved.push(candidate); return { inserted: true }; } },
|
||||||
|
});
|
||||||
|
await pipeline.observeWrite({
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [{ role: 'user', text: '我的长期目标是建设一个持续成长的 Personal Agent。' }],
|
||||||
|
});
|
||||||
|
assert.equal(saved.length, 1);
|
||||||
|
assert.equal(saved[0].memoryType, 'goal');
|
||||||
|
assert.equal(pipeline.getStatus().persistence, 'mysql');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Memory V2 does not wait for the shadow pipeline before returning legacy write result', async () => {
|
||||||
|
let releaseShadow;
|
||||||
|
const shadowBlocked = new Promise((resolve) => { releaseShadow = resolve; });
|
||||||
|
const memory = createMemoryV2({
|
||||||
|
policy: { enabled: true, eventLogEnabled: true, failOpen: true, backend: 'legacy' },
|
||||||
|
backends: [{
|
||||||
|
name: 'legacy-conversation-memory',
|
||||||
|
isAvailable: () => true,
|
||||||
|
async write() { return { saved: 1, analyzed: 0, memories: 0 }; },
|
||||||
|
}],
|
||||||
|
personalShadowPipeline: {
|
||||||
|
config: { enabled: true },
|
||||||
|
observeWrite: () => shadowBlocked,
|
||||||
|
getStatus: () => ({ enabled: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await Promise.race([
|
||||||
|
memory.write({ userId: 'u1', sessionId: 's1', messages: [] }),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('write waited for shadow')), 50)),
|
||||||
|
]);
|
||||||
|
assert.equal(result.saved, 1);
|
||||||
|
releaseShadow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Memory V2 exposes a shadow-only observation entry without calling legacy write', async () => {
|
||||||
|
let legacyWrites = 0;
|
||||||
|
const observed = [];
|
||||||
|
const memory = createMemoryV2({
|
||||||
|
policy: { enabled: true, eventLogEnabled: true, failOpen: true, backend: 'legacy' },
|
||||||
|
backends: [{
|
||||||
|
name: 'legacy-conversation-memory',
|
||||||
|
isAvailable: () => true,
|
||||||
|
async write() { legacyWrites += 1; return { saved: 1 }; },
|
||||||
|
}],
|
||||||
|
personalShadowPipeline: {
|
||||||
|
config: { enabled: true },
|
||||||
|
async observeWrite(input) { observed.push(input); return { accepted: 1 }; },
|
||||||
|
getStatus: () => ({ enabled: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await memory.observePersonalMemory({
|
||||||
|
userId: 'u1',
|
||||||
|
sessionId: 's1',
|
||||||
|
messages: [{ role: 'user', text: '我决定先完成测试。' }],
|
||||||
|
});
|
||||||
|
assert.equal(result.accepted, 1);
|
||||||
|
assert.equal(observed.length, 1);
|
||||||
|
assert.equal(legacyWrites, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const TABLE = 'h5_memory_v2_candidates';
|
||||||
|
const ALLOWED_STATUSES = new Set(['candidate', 'accepted', 'rejected', 'forgotten']);
|
||||||
|
|
||||||
|
export function buildPersonalMemoryCandidateSchemaSql({ table = TABLE } = {}) {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error('Invalid candidate table name');
|
||||||
|
return `CREATE TABLE IF NOT EXISTS \`${table}\` (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
session_id VARCHAR(191) NULL,
|
||||||
|
memory_type VARCHAR(32) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
importance DECIMAL(5,4) NOT NULL,
|
||||||
|
confidence DECIMAL(5,4) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'candidate',
|
||||||
|
policy_reason VARCHAR(64) NOT NULL,
|
||||||
|
evidence_json JSON NOT NULL,
|
||||||
|
reviewed_by CHAR(36) NULL,
|
||||||
|
reviewed_at BIGINT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uniq_user_type_content (user_id, memory_type, id),
|
||||||
|
KEY idx_candidate_status_created (status, created_at),
|
||||||
|
KEY idx_candidate_user_status (user_id, status, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePersonalMemoryCandidateSchema(pool, options = {}) {
|
||||||
|
if (!pool?.query) throw new Error('Candidate schema requires a MySQL pool');
|
||||||
|
await pool.query(buildPersonalMemoryCandidateSchemaSql(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value, fallback = null) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
try { return JSON.parse(value); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRow(row) {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
userId: String(row.user_id),
|
||||||
|
sessionId: row.session_id == null ? null : String(row.session_id),
|
||||||
|
memoryType: String(row.memory_type),
|
||||||
|
content: String(row.content),
|
||||||
|
importance: Number(row.importance),
|
||||||
|
confidence: Number(row.confidence),
|
||||||
|
status: String(row.status),
|
||||||
|
policyReason: String(row.policy_reason),
|
||||||
|
evidence: parseJson(row.evidence_json, {}),
|
||||||
|
reviewedBy: row.reviewed_by == null ? null : String(row.reviewed_by),
|
||||||
|
reviewedAt: row.reviewed_at == null ? null : Number(row.reviewed_at),
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
updatedAt: Number(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPersonalMemoryCandidateStore(pool, { table = TABLE, now = () => Date.now() } = {}) {
|
||||||
|
if (!pool?.query) return null;
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error('Invalid candidate table name');
|
||||||
|
const quoted = `\`${table}\``;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async saveCandidate(candidate, { autoAccept = false, reviewedBy = 'system:auto-review' } = {}) {
|
||||||
|
const updatedAt = now();
|
||||||
|
const status = autoAccept ? 'accepted' : 'candidate';
|
||||||
|
const reviewedAt = autoAccept ? updatedAt : null;
|
||||||
|
const reviewer = autoAccept ? String(reviewedBy || 'system:auto-review') : null;
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT IGNORE INTO ${quoted}
|
||||||
|
(id, user_id, session_id, memory_type, content, importance, confidence, status,
|
||||||
|
policy_reason, evidence_json, reviewed_by, reviewed_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
candidate.id,
|
||||||
|
candidate.userId,
|
||||||
|
candidate.sessionId,
|
||||||
|
candidate.memoryType,
|
||||||
|
candidate.content,
|
||||||
|
candidate.importance,
|
||||||
|
candidate.confidence,
|
||||||
|
status,
|
||||||
|
candidate.policyReason,
|
||||||
|
JSON.stringify(candidate.evidence ?? {}),
|
||||||
|
reviewer,
|
||||||
|
reviewedAt,
|
||||||
|
candidate.createdAt,
|
||||||
|
updatedAt,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { inserted: Number(result?.affectedRows ?? 0) > 0, status };
|
||||||
|
},
|
||||||
|
|
||||||
|
async listCandidates({ status = 'candidate', userId = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const normalizedStatus = String(status || 'candidate');
|
||||||
|
if (!ALLOWED_STATUSES.has(normalizedStatus)) throw new Error('Invalid candidate status');
|
||||||
|
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50));
|
||||||
|
const safeOffset = Math.max(0, Number(offset) || 0);
|
||||||
|
const where = ['status = ?'];
|
||||||
|
const params = [normalizedStatus];
|
||||||
|
if (userId) {
|
||||||
|
where.push('user_id = ?');
|
||||||
|
params.push(String(userId));
|
||||||
|
}
|
||||||
|
params.push(safeLimit, safeOffset);
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT * FROM ${quoted} WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows.map(normalizeRow);
|
||||||
|
},
|
||||||
|
|
||||||
|
async countByStatus() {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT status, COUNT(*) AS count FROM ${quoted} GROUP BY status`,
|
||||||
|
);
|
||||||
|
return Object.fromEntries(rows.map((row) => [String(row.status), Number(row.count)]));
|
||||||
|
},
|
||||||
|
|
||||||
|
async reviewCandidate(id, status, { reviewedBy = null } = {}) {
|
||||||
|
if (!['accepted', 'rejected'].includes(status)) throw new Error('Invalid review status');
|
||||||
|
const reviewedAt = now();
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`UPDATE ${quoted}
|
||||||
|
SET status = ?, reviewed_by = ?, reviewed_at = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'candidate'`,
|
||||||
|
[status, reviewedBy, reviewedAt, reviewedAt, String(id)],
|
||||||
|
);
|
||||||
|
return { updated: Number(result?.affectedRows ?? 0) > 0, status, reviewedAt };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildPersonalMemoryCandidateSchemaSql,
|
||||||
|
createPersonalMemoryCandidateStore,
|
||||||
|
ensurePersonalMemoryCandidateSchema,
|
||||||
|
} from './memory-v2-personal-store.mjs';
|
||||||
|
|
||||||
|
test('candidate schema is explicit and idempotent', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const pool = { async query(sql, params) { calls.push({ sql, params }); return [[], []]; } };
|
||||||
|
await ensurePersonalMemoryCandidateSchema(pool);
|
||||||
|
assert.match(calls[0].sql, /CREATE TABLE IF NOT EXISTS/);
|
||||||
|
assert.match(calls[0].sql, /h5_memory_v2_candidates/);
|
||||||
|
assert.throws(() => buildPersonalMemoryCandidateSchemaSql({ table: 'bad-name' }), /Invalid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('candidate store saves, lists, counts, and reviews with parameterized SQL', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
if (sql.startsWith('INSERT')) return [{ affectedRows: 1 }, []];
|
||||||
|
if (sql.startsWith('SELECT *')) return [[{
|
||||||
|
id: 'pmc_1', user_id: 'u1', session_id: 's1', memory_type: 'episodic',
|
||||||
|
content: '决定使用 main 发布', importance: '0.9', confidence: '0.9', status: 'candidate',
|
||||||
|
policy_reason: 'decision_signal', evidence_json: '{"sourceId":"s1:0"}',
|
||||||
|
reviewed_by: null, reviewed_at: null, created_at: 1, updated_at: 1,
|
||||||
|
}], []];
|
||||||
|
if (sql.startsWith('SELECT status')) return [[{ status: 'candidate', count: 1 }], []];
|
||||||
|
if (sql.startsWith('UPDATE')) return [{ affectedRows: 1 }, []];
|
||||||
|
throw new Error(`Unexpected SQL: ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = createPersonalMemoryCandidateStore(pool, { now: () => 10 });
|
||||||
|
assert.deepEqual(await store.saveCandidate({
|
||||||
|
id: 'pmc_1', userId: 'u1', sessionId: 's1', memoryType: 'episodic',
|
||||||
|
content: '决定使用 main 发布', importance: 0.9, confidence: 0.9,
|
||||||
|
policyReason: 'decision_signal', evidence: { sourceId: 's1:0' }, createdAt: 1,
|
||||||
|
}, { autoAccept: true }), { inserted: true, status: 'accepted' });
|
||||||
|
const rows = await store.listCandidates({ limit: 20 });
|
||||||
|
assert.equal(rows[0].evidence.sourceId, 's1:0');
|
||||||
|
assert.deepEqual(await store.countByStatus(), { candidate: 1 });
|
||||||
|
assert.equal((await store.reviewCandidate('pmc_1', 'accepted', { reviewedBy: 'admin-1' })).updated, true);
|
||||||
|
assert.ok(calls.every((call) => !call.sql.includes('admin-1')));
|
||||||
|
});
|
||||||
|
|
||||||
@@ -4,6 +4,8 @@ import { createLangGraphHttpClient, createLangGraphMemoryBackend } from './memor
|
|||||||
import { createLegacyMemoryBackend, createMemoryV2, resolveMemoryV2Policy } from './memory-v2.mjs';
|
import { createLegacyMemoryBackend, createMemoryV2, resolveMemoryV2Policy } from './memory-v2.mjs';
|
||||||
import { createLettaHttpClient, createLettaMemoryBackend } from './memory-v2-letta.mjs';
|
import { createLettaHttpClient, createLettaMemoryBackend } from './memory-v2-letta.mjs';
|
||||||
import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs';
|
import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs';
|
||||||
|
import { createPersonalMemoryShadowPipeline } from './memory-v2-personal-shadow.mjs';
|
||||||
|
import { createPersonalMemoryCandidateStore } from './memory-v2-personal-store.mjs';
|
||||||
import { createMem0HttpClient, createMem0MemoryBackend } from './memory-v2-mem0.mjs';
|
import { createMem0HttpClient, createMem0MemoryBackend } from './memory-v2-mem0.mjs';
|
||||||
import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs';
|
import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs';
|
||||||
import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs';
|
import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs';
|
||||||
@@ -400,11 +402,19 @@ export async function createMemoryV2Runtime({
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const personalCandidateStore = readFlag(env, 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', false)
|
||||||
|
? createPersonalMemoryCandidateStore(mysqlPool)
|
||||||
|
: null;
|
||||||
|
const personalShadowPipeline = createPersonalMemoryShadowPipeline({
|
||||||
|
env,
|
||||||
|
store: personalCandidateStore,
|
||||||
|
});
|
||||||
const memory = createMemoryV2({
|
const memory = createMemoryV2({
|
||||||
legacyMemoryService,
|
legacyMemoryService,
|
||||||
backends,
|
backends,
|
||||||
env,
|
env,
|
||||||
logger,
|
logger,
|
||||||
|
personalShadowPipeline,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pgBackfillEnabled = readFlag(env, 'MEMORY_PGVECTOR_BACKFILL_ENABLED', pgvectorEnabled);
|
const pgBackfillEnabled = readFlag(env, 'MEMORY_PGVECTOR_BACKFILL_ENABLED', pgvectorEnabled);
|
||||||
@@ -593,6 +603,11 @@ export async function createManagedMemoryV2Runtime({
|
|||||||
return runtime.compact(input);
|
return runtime.compact(input);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async observePersonalMemory(input = {}) {
|
||||||
|
const runtime = await ensureRuntime();
|
||||||
|
return runtime.observePersonalMemory(input);
|
||||||
|
},
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
const runtime = activeRuntime;
|
const runtime = activeRuntime;
|
||||||
activeRuntime = null;
|
activeRuntime = null;
|
||||||
|
|||||||
+34
-1
@@ -1,4 +1,5 @@
|
|||||||
import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs';
|
import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs';
|
||||||
|
import { createPersonalMemoryShadowPipeline } from './memory-v2-personal-shadow.mjs';
|
||||||
|
|
||||||
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||||
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
|
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
|
||||||
@@ -231,12 +232,14 @@ export function createMemoryV2({
|
|||||||
policy = null,
|
policy = null,
|
||||||
env = process.env,
|
env = process.env,
|
||||||
logger = console,
|
logger = console,
|
||||||
|
personalShadowPipeline = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const resolvedPolicy = policy ?? resolveMemoryV2Policy({ env });
|
const resolvedPolicy = policy ?? resolveMemoryV2Policy({ env });
|
||||||
const resolvedBackends = backends ?? [
|
const resolvedBackends = backends ?? [
|
||||||
createLegacyMemoryBackend(legacyMemoryService),
|
createLegacyMemoryBackend(legacyMemoryService),
|
||||||
...createMemoryV2PluginBackends(),
|
...createMemoryV2PluginBackends(),
|
||||||
];
|
];
|
||||||
|
const shadowPipeline = personalShadowPipeline ?? createPersonalMemoryShadowPipeline({ env });
|
||||||
|
|
||||||
async function failOpen(operation, err, fallback) {
|
async function failOpen(operation, err, fallback) {
|
||||||
logger?.warn?.(
|
logger?.warn?.(
|
||||||
@@ -278,6 +281,13 @@ export function createMemoryV2({
|
|||||||
if (!backend?.write) return skippedWriteResult('no_backend');
|
if (!backend?.write) return skippedWriteResult('no_backend');
|
||||||
try {
|
try {
|
||||||
const result = await backend.write(input);
|
const result = await backend.write(input);
|
||||||
|
if (shadowPipeline?.config?.enabled) {
|
||||||
|
void Promise.resolve(shadowPipeline.observeWrite(input)).catch((err) => {
|
||||||
|
logger?.warn?.(
|
||||||
|
`[memory-v2] personal shadow pipeline skipped: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -334,10 +344,28 @@ export function createMemoryV2({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function observePersonalMemory(input = {}) {
|
||||||
|
if (!shadowPipeline?.config?.enabled) {
|
||||||
|
return { enabled: false, skipped: true, reason: 'disabled' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await shadowPipeline.observeWrite(input);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn?.(
|
||||||
|
`[memory-v2] personal shadow observation skipped: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
skipped: true,
|
||||||
|
reason: 'pipeline_failure',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getStatus() {
|
function getStatus() {
|
||||||
const backends = resolvedBackends.map((backend) => backendStatus(backend));
|
const backends = resolvedBackends.map((backend) => backendStatus(backend));
|
||||||
const selected = selectBackend(resolvedBackends, resolvedPolicy, 'resolve');
|
const selected = selectBackend(resolvedBackends, resolvedPolicy, 'resolve');
|
||||||
return {
|
const status = {
|
||||||
enabled: Boolean(resolvedPolicy.enabled),
|
enabled: Boolean(resolvedPolicy.enabled),
|
||||||
backend: resolvedPolicy.backend,
|
backend: resolvedPolicy.backend,
|
||||||
selectedBackend: selected?.name ?? null,
|
selectedBackend: selected?.name ?? null,
|
||||||
@@ -347,6 +375,10 @@ export function createMemoryV2({
|
|||||||
failOpen: Boolean(resolvedPolicy.failOpen),
|
failOpen: Boolean(resolvedPolicy.failOpen),
|
||||||
backends,
|
backends,
|
||||||
};
|
};
|
||||||
|
if (shadowPipeline?.config?.enabled) {
|
||||||
|
status.personalMemory = shadowPipeline.getStatus();
|
||||||
|
}
|
||||||
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -355,5 +387,6 @@ export function createMemoryV2({
|
|||||||
resolve,
|
resolve,
|
||||||
write,
|
write,
|
||||||
compact,
|
compact,
|
||||||
|
observePersonalMemory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createDbPool } from '../db.mjs';
|
||||||
|
import { ensurePersonalMemoryCandidateSchema } from '../memory-v2-personal-store.mjs';
|
||||||
|
|
||||||
|
if (process.env.MEMORY_PERSONAL_SCHEMA_CONFIRM !== 'local-only') {
|
||||||
|
throw new Error('Refusing schema change: set MEMORY_PERSONAL_SCHEMA_CONFIRM=local-only explicitly');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = createDbPool();
|
||||||
|
try {
|
||||||
|
await ensurePersonalMemoryCandidateSchema(pool);
|
||||||
|
console.log('Memory V2 personal candidate schema ready.');
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
+62
-21
@@ -192,6 +192,7 @@ import { createSessionSnapshotService } from './session-snapshot.mjs';
|
|||||||
import { createConversationMemoryService } from './conversation-memory.mjs';
|
import { createConversationMemoryService } from './conversation-memory.mjs';
|
||||||
import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs';
|
import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs';
|
||||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||||
|
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
|
||||||
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
||||||
import { createExperienceService } from './experience-service.mjs';
|
import { createExperienceService } from './experience-service.mjs';
|
||||||
import { attachAsrRoutes } from './asr-proxy.mjs';
|
import { attachAsrRoutes } from './asr-proxy.mjs';
|
||||||
@@ -202,24 +203,16 @@ import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs';
|
|||||||
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
|
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
|
||||||
import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs';
|
import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs';
|
||||||
import { isNativeH5ApiPath } from './policies.mjs';
|
import { isNativeH5ApiPath } from './policies.mjs';
|
||||||
|
import {
|
||||||
|
applyMemindRuntimeProfile,
|
||||||
|
describeMemindRuntimeProfile,
|
||||||
|
loadMemindEnvFiles,
|
||||||
|
} from './scripts/memind-runtime-profile.mjs';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function loadEnvFile(filePath) {
|
loadMemindEnvFiles(__dirname);
|
||||||
if (!fs.existsSync(filePath)) return;
|
applyMemindRuntimeProfile({ rootDir: __dirname });
|
||||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
||||||
const eq = trimmed.indexOf('=');
|
|
||||||
if (eq < 0) continue;
|
|
||||||
const key = trimmed.slice(0, eq).trim();
|
|
||||||
const value = trimmed.slice(eq + 1).trim();
|
|
||||||
if (!process.env[key]) process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEnvFile(path.join(__dirname, '../../.env.local'));
|
|
||||||
loadEnvFile(path.join(__dirname, '.env'));
|
|
||||||
|
|
||||||
function parseApiTargets() {
|
function parseApiTargets() {
|
||||||
const csvTargets = (process.env.TKMIND_API_TARGETS ?? '')
|
const csvTargets = (process.env.TKMIND_API_TARGETS ?? '')
|
||||||
@@ -349,6 +342,15 @@ async function unregisterAgentSessionForUser(userId, sessionId) {
|
|||||||
await access.unregisterSession({ userId, sessionId });
|
await access.unregisterSession({ userId, sessionId });
|
||||||
}
|
}
|
||||||
let agentRunGateway = null;
|
let agentRunGateway = null;
|
||||||
|
const sessionPageDeliveryLocks = new Map();
|
||||||
|
function beginSessionPageDelivery(sessionId) {
|
||||||
|
sessionPageDeliveryLocks.set(sessionId, Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
function endSessionPageDelivery(sessionId) {
|
||||||
|
const remaining = Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) - 1;
|
||||||
|
if (remaining > 0) sessionPageDeliveryLocks.set(sessionId, remaining);
|
||||||
|
else sessionPageDeliveryLocks.delete(sessionId);
|
||||||
|
}
|
||||||
let chatIntentRouter = null;
|
let chatIntentRouter = null;
|
||||||
let toolGateway = null;
|
let toolGateway = null;
|
||||||
let directChatService = null;
|
let directChatService = null;
|
||||||
@@ -356,6 +358,7 @@ let sessionSnapshotService = null;
|
|||||||
let conversationMemoryService = null;
|
let conversationMemoryService = null;
|
||||||
let memoryV2 = null;
|
let memoryV2 = null;
|
||||||
let memoryV2ConfigService = null;
|
let memoryV2ConfigService = null;
|
||||||
|
let skillRuntimeConfigService = null;
|
||||||
let wechatScheduleLlmConfigService = null;
|
let wechatScheduleLlmConfigService = null;
|
||||||
let mindSpace = null;
|
let mindSpace = null;
|
||||||
let mindSpaceAssets = null;
|
let mindSpaceAssets = null;
|
||||||
@@ -639,6 +642,7 @@ async function bootstrapUserAuth() {
|
|||||||
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
|
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
|
||||||
});
|
});
|
||||||
memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||||
|
skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root: __dirname });
|
||||||
wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||||
conversationMemoryService = createConversationMemoryService(pool, {
|
conversationMemoryService = createConversationMemoryService(pool, {
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
@@ -705,9 +709,16 @@ async function bootstrapUserAuth() {
|
|||||||
chatIntentRouter,
|
chatIntentRouter,
|
||||||
sessionSnapshotService,
|
sessionSnapshotService,
|
||||||
conversationMemoryService,
|
conversationMemoryService,
|
||||||
syncUserPagesOnSuccess: async ({ userId }) => {
|
observePersonalMemoryOnSuccess: async ({ userId, sessionId, userMessage }) => {
|
||||||
await syncUserGeneratedPages(userId);
|
if (!memoryV2?.observePersonalMemory) return;
|
||||||
|
await memoryV2.observePersonalMemory({
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
messages: [userMessage],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
syncUserPagesOnSuccess: async ({ userId }) => syncUserGeneratedPages(userId),
|
||||||
|
isSessionExternallyBusy: ({ sessionId }) => Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) > 0,
|
||||||
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
|
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
|
||||||
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
|
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
|
||||||
),
|
),
|
||||||
@@ -851,6 +862,16 @@ app.use(attachUserSession);
|
|||||||
|
|
||||||
// ============ Legacy password auth ============
|
// ============ Legacy password auth ============
|
||||||
|
|
||||||
|
async function resolveSkillRuntimeForClient() {
|
||||||
|
if (!skillRuntimeConfigService?.getPublicRuntimeConfig) return null;
|
||||||
|
try {
|
||||||
|
return await skillRuntimeConfigService.getPublicRuntimeConfig();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SkillRuntime] public config unavailable:', err instanceof Error ? err.message : err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/auth/status', async (req, res) => {
|
app.get('/auth/status', async (req, res) => {
|
||||||
await userAuthReady;
|
await userAuthReady;
|
||||||
if (userAuth) {
|
if (userAuth) {
|
||||||
@@ -859,6 +880,7 @@ app.get('/auth/status', async (req, res) => {
|
|||||||
if (!me) return res.json({ authenticated: false, mode: 'user' });
|
if (!me) return res.json({ authenticated: false, mode: 'user' });
|
||||||
const row = await userAuth.getUserById(me.id);
|
const row = await userAuth.getUserById(me.id);
|
||||||
const capabilityState = await userAuth.resolveUserCapabilities(row);
|
const capabilityState = await userAuth.resolveUserCapabilities(row);
|
||||||
|
const skillRuntime = await resolveSkillRuntimeForClient();
|
||||||
return res.json({
|
return res.json({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
user: me,
|
user: me,
|
||||||
@@ -866,6 +888,7 @@ app.get('/auth/status', async (req, res) => {
|
|||||||
capabilities: capabilityState.capabilities,
|
capabilities: capabilityState.capabilities,
|
||||||
grantedSkills: capabilityState.grantedSkills ?? [],
|
grantedSkills: capabilityState.grantedSkills ?? [],
|
||||||
unrestricted: capabilityState.unrestricted,
|
unrestricted: capabilityState.unrestricted,
|
||||||
|
skillRuntime,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Auth] status failed:', err instanceof Error ? err.message : err);
|
console.error('[Auth] status failed:', err instanceof Error ? err.message : err);
|
||||||
@@ -1309,10 +1332,11 @@ app.get('/auth/me', async (req, res) => {
|
|||||||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||||||
const me = await userAuth.getMe(userToken(req));
|
const me = await userAuth.getMe(userToken(req));
|
||||||
if (!me) return res.status(401).json({ message: '未登录' });
|
if (!me) return res.status(401).json({ message: '未登录' });
|
||||||
const [paths, capabilityState, subscription] = await Promise.all([
|
const [paths, capabilityState, subscription, skillRuntime] = await Promise.all([
|
||||||
userAuth.listPathGrants(me.id),
|
userAuth.listPathGrants(me.id),
|
||||||
userAuth.resolveUserCapabilities(await userAuth.getUserById(me.id)),
|
userAuth.resolveUserCapabilities(await userAuth.getUserById(me.id)),
|
||||||
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
|
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
|
||||||
|
resolveSkillRuntimeForClient(),
|
||||||
]);
|
]);
|
||||||
return res.json({
|
return res.json({
|
||||||
user: { ...me, subscription },
|
user: { ...me, subscription },
|
||||||
@@ -1320,6 +1344,7 @@ app.get('/auth/me', async (req, res) => {
|
|||||||
capabilities: capabilityState.capabilities,
|
capabilities: capabilityState.capabilities,
|
||||||
grantedSkills: capabilityState.grantedSkills ?? [],
|
grantedSkills: capabilityState.grantedSkills ?? [],
|
||||||
unrestricted: capabilityState.unrestricted,
|
unrestricted: capabilityState.unrestricted,
|
||||||
|
skillRuntime,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3209,11 +3234,10 @@ const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
|
|||||||
async function syncUserGeneratedPages(userId) {
|
async function syncUserGeneratedPages(userId) {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
if (workspacePageDeliver?.syncAndDeliver) {
|
if (workspacePageDeliver?.syncAndDeliver) {
|
||||||
await workspacePageDeliver.syncAndDeliver(userId);
|
return await workspacePageDeliver.syncAndDeliver(userId);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!mindSpacePageSync) return;
|
if (!mindSpacePageSync) return;
|
||||||
await mindSpacePageSync.syncUserGeneratedPages(userId);
|
return await mindSpacePageSync.syncUserGeneratedPages(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveChatSaveBundle(user, h5Root, input = {}) {
|
async function resolveChatSaveBundle(user, h5Root, input = {}) {
|
||||||
@@ -5004,6 +5028,8 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
|||||||
// workspace HTML into the asset store before a later restart rebuilds the
|
// workspace HTML into the asset store before a later restart rebuilds the
|
||||||
// workspace from DB-backed assets only.
|
// workspace from DB-backed assets only.
|
||||||
const onAfterFinish = async (sid, uid) => {
|
const onAfterFinish = async (sid, uid) => {
|
||||||
|
beginSessionPageDelivery(sid);
|
||||||
|
try {
|
||||||
const apiFetchFn = async (pathname, init) => {
|
const apiFetchFn = async (pathname, init) => {
|
||||||
const target = await tkmindProxy.resolveTarget(sid);
|
const target = await tkmindProxy.resolveTarget(sid);
|
||||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||||
@@ -5085,6 +5111,20 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
|||||||
tkmindProxy,
|
tkmindProxy,
|
||||||
userText: lastUserText,
|
userText: lastUserText,
|
||||||
});
|
});
|
||||||
|
if (lastUserMessage && memoryV2?.observePersonalMemory) {
|
||||||
|
await memoryV2.observePersonalMemory({
|
||||||
|
userId: uid,
|
||||||
|
sessionId: sid,
|
||||||
|
messages: [lastUserMessage],
|
||||||
|
}).catch((err) => {
|
||||||
|
console.warn(
|
||||||
|
`[memory-v2] finish shadow observation skipped for session ${sid}: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
endSessionPageDelivery(sid);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
|
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
|
||||||
onAfterFinish,
|
onAfterFinish,
|
||||||
@@ -6233,6 +6273,7 @@ app.get('*', (_req, res) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const server = app.listen(PORT, HOST, () => {
|
const server = app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`[Portal] Runtime profile: ${describeMemindRuntimeProfile()}`);
|
||||||
console.log(`TKMind H5 @ http://${HOST}:${PORT}`);
|
console.log(`TKMind H5 @ http://${HOST}:${PORT}`);
|
||||||
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
|
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
|
||||||
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
|
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { SKILL_ROUTER_V2_ENV } from './chat-skills.mjs';
|
||||||
|
import {
|
||||||
|
buildPublicSkillRuntimeConfig,
|
||||||
|
buildSkillRuntimeCatalogSummary,
|
||||||
|
} from './skill-runtime-policy.mjs';
|
||||||
|
|
||||||
|
const CONFIG_TABLE = 'h5_skill_runtime_config';
|
||||||
|
const CONFIG_SCOPE = 'global';
|
||||||
|
|
||||||
|
function defaultConfigShape() {
|
||||||
|
return {
|
||||||
|
router: {
|
||||||
|
v2Enabled: false,
|
||||||
|
manifestRoutingEnabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoolean(value, fallback = false) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneConfig(config = null) {
|
||||||
|
return structuredClone?.(config ?? defaultConfigShape())
|
||||||
|
?? JSON.parse(JSON.stringify(config ?? defaultConfigShape()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonLike(value, fallback) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envRouterEnabled(env = process.env) {
|
||||||
|
return /^(1|true|yes)$/i.test(String(env?.[SKILL_ROUTER_V2_ENV] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEnv(config, env = process.env) {
|
||||||
|
const next = cloneConfig(config);
|
||||||
|
if (next.router.v2Enabled === false && envRouterEnabled(env)) {
|
||||||
|
next.router.v2Enabled = true;
|
||||||
|
}
|
||||||
|
if (next.router.manifestRoutingEnabled === false && next.router.v2Enabled) {
|
||||||
|
next.router.manifestRoutingEnabled = envRouterEnabled(env);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePatch(currentConfig, patch = {}) {
|
||||||
|
const next = cloneConfig(currentConfig);
|
||||||
|
if (patch?.router && 'v2Enabled' in patch.router) {
|
||||||
|
next.router.v2Enabled = normalizeBoolean(patch.router.v2Enabled, false);
|
||||||
|
}
|
||||||
|
if (patch?.router && 'manifestRoutingEnabled' in patch.router) {
|
||||||
|
next.router.manifestRoutingEnabled = normalizeBoolean(patch.router.manifestRoutingEnabled, false);
|
||||||
|
}
|
||||||
|
if (!next.router.v2Enabled) {
|
||||||
|
next.router.manifestRoutingEnabled = false;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureConfigTable(pool) {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
|
||||||
|
config_scope VARCHAR(32) PRIMARY KEY,
|
||||||
|
config_json JSON NOT NULL,
|
||||||
|
updated_by CHAR(36) NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStoredState(pool) {
|
||||||
|
await ensureConfigTable(pool);
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT config_json, updated_by, updated_at
|
||||||
|
FROM ${CONFIG_TABLE}
|
||||||
|
WHERE config_scope = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[CONFIG_SCOPE],
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
config: { ...defaultConfigShape(), ...parseJsonLike(row.config_json, {}) },
|
||||||
|
updatedAt: Number(row.updated_at ?? 0) || null,
|
||||||
|
updatedBy: row.updated_by ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSkillRuntimeAdminConfigService(pool, { env = process.env, h5Root = process.cwd() } = {}) {
|
||||||
|
async function loadEffectiveConfig() {
|
||||||
|
const stored = await loadStoredState(pool);
|
||||||
|
if (!stored) {
|
||||||
|
const config = applyEnv(defaultConfigShape(), env);
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null,
|
||||||
|
source: envRouterEnabled(env) ? 'env' : 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
config: cloneConfig(stored.config),
|
||||||
|
updatedAt: stored.updatedAt,
|
||||||
|
updatedBy: stored.updatedBy,
|
||||||
|
source: 'admin-db',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getAdminConfig() {
|
||||||
|
const state = await loadEffectiveConfig();
|
||||||
|
return {
|
||||||
|
config: state.config,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
updatedBy: state.updatedBy,
|
||||||
|
source: state.source,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
|
||||||
|
const stored = await loadStoredState(pool);
|
||||||
|
const base = stored?.config ?? defaultConfigShape();
|
||||||
|
const nextConfig = mergePatch(base, patch.config ?? patch);
|
||||||
|
await ensureConfigTable(pool);
|
||||||
|
const now = Date.now();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO ${CONFIG_TABLE}
|
||||||
|
(config_scope, config_json, updated_by, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
config_json = VALUES(config_json),
|
||||||
|
updated_by = VALUES(updated_by),
|
||||||
|
updated_at = VALUES(updated_at)`,
|
||||||
|
[CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now],
|
||||||
|
);
|
||||||
|
return this.getAdminConfig();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRuntimeState() {
|
||||||
|
const state = await loadEffectiveConfig();
|
||||||
|
return {
|
||||||
|
source: state.source,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
updatedBy: state.updatedBy,
|
||||||
|
config: state.config,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPublicRuntimeConfig() {
|
||||||
|
const state = await loadEffectiveConfig();
|
||||||
|
return buildPublicSkillRuntimeConfig(state.config, h5Root);
|
||||||
|
},
|
||||||
|
|
||||||
|
listCatalogSummary() {
|
||||||
|
return buildSkillRuntimeCatalogSummary(h5Root);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skillRuntimeAdminConfigInternals = {
|
||||||
|
CONFIG_SCOPE,
|
||||||
|
CONFIG_TABLE,
|
||||||
|
defaultConfigShape,
|
||||||
|
applyEnv,
|
||||||
|
mergePatch,
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createSkillRuntimeAdminConfigService,
|
||||||
|
skillRuntimeAdminConfigInternals,
|
||||||
|
} from './skill-runtime-admin-config.mjs';
|
||||||
|
|
||||||
|
function createPool(seedRow = null) {
|
||||||
|
const state = { row: seedRow };
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
async query(sql, params) {
|
||||||
|
if (sql.includes('CREATE TABLE')) return [[], []];
|
||||||
|
if (sql.includes('SELECT config_json')) {
|
||||||
|
return [state.row ? [state.row] : [], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO h5_skill_runtime_config')) {
|
||||||
|
state.row = {
|
||||||
|
config_json: params[1],
|
||||||
|
updated_by: params[2],
|
||||||
|
updated_at: params[3],
|
||||||
|
};
|
||||||
|
return [[], []];
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected query: ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('skill runtime admin config defaults to disabled router', async () => {
|
||||||
|
const service = createSkillRuntimeAdminConfigService(createPool(), { env: {} });
|
||||||
|
const result = await service.getAdminConfig();
|
||||||
|
assert.equal(result.config.router.v2Enabled, false);
|
||||||
|
assert.equal(result.config.router.manifestRoutingEnabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skill runtime admin config falls back to env when db row is absent', async () => {
|
||||||
|
const service = createSkillRuntimeAdminConfigService(createPool(), {
|
||||||
|
env: { TKMIND_SKILL_ROUTER_V2: '1' },
|
||||||
|
});
|
||||||
|
const result = await service.getAdminConfig();
|
||||||
|
assert.equal(result.config.router.v2Enabled, true);
|
||||||
|
assert.equal(result.config.router.manifestRoutingEnabled, true);
|
||||||
|
assert.equal(result.source, 'env');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skill runtime admin config persists router toggles', async () => {
|
||||||
|
const pool = createPool();
|
||||||
|
const service = createSkillRuntimeAdminConfigService(pool, { env: {} });
|
||||||
|
const updated = await service.updateAdminConfig(
|
||||||
|
{ router: { v2Enabled: true, manifestRoutingEnabled: true } },
|
||||||
|
{ updatedBy: 'admin-1' },
|
||||||
|
);
|
||||||
|
assert.equal(updated.config.router.v2Enabled, true);
|
||||||
|
assert.equal(updated.config.router.manifestRoutingEnabled, true);
|
||||||
|
const runtime = await service.getPublicRuntimeConfig();
|
||||||
|
assert.equal(runtime.routerV2Enabled, true);
|
||||||
|
assert.equal(runtime.manifestRoutingEnabled, true);
|
||||||
|
assert.ok(Array.isArray(runtime.manifestRoutes));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skill runtime admin config keeps db authoritative over env', async () => {
|
||||||
|
const pool = createPool({
|
||||||
|
config_json: JSON.stringify(skillRuntimeAdminConfigInternals.defaultConfigShape()),
|
||||||
|
updated_by: 'admin-1',
|
||||||
|
updated_at: 123,
|
||||||
|
});
|
||||||
|
const service = createSkillRuntimeAdminConfigService(pool, {
|
||||||
|
env: { TKMIND_SKILL_ROUTER_V2: '1' },
|
||||||
|
});
|
||||||
|
const result = await service.getAdminConfig();
|
||||||
|
assert.equal(result.config.router.v2Enabled, false);
|
||||||
|
assert.equal(result.source, 'admin-db');
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { buildManifestRoutesFromCatalog } from './chat-skills.mjs';
|
||||||
|
import { listPlatformSkillCatalog } from './skills-registry.mjs';
|
||||||
|
|
||||||
|
export function buildPublicSkillRuntimeConfig(config, h5Root = process.cwd()) {
|
||||||
|
const routerV2Enabled = Boolean(config?.router?.v2Enabled);
|
||||||
|
const manifestRoutingEnabled = routerV2Enabled && Boolean(config?.router?.manifestRoutingEnabled);
|
||||||
|
const catalog = listPlatformSkillCatalog(h5Root);
|
||||||
|
const manifestRoutes = manifestRoutingEnabled ? buildManifestRoutesFromCatalog(catalog) : [];
|
||||||
|
return {
|
||||||
|
routerV2Enabled,
|
||||||
|
manifestRoutingEnabled,
|
||||||
|
manifestRoutes,
|
||||||
|
manifestSkillCount: catalog.filter((item) => item.manifest?.trigger?.keywords?.length).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSkillRuntimeCatalogSummary(h5Root = process.cwd()) {
|
||||||
|
const catalog = listPlatformSkillCatalog(h5Root);
|
||||||
|
return catalog.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
dirName: item.dirName,
|
||||||
|
description: item.description,
|
||||||
|
version: item.version ?? null,
|
||||||
|
executors: item.executors ?? ['goose'],
|
||||||
|
hasManifest: Boolean(item.manifest),
|
||||||
|
triggerKeywords: item.manifest?.trigger?.keywords ?? [],
|
||||||
|
routerPromptKey: item.manifest?.router?.promptKey ?? null,
|
||||||
|
routerPriority: item.manifest?.router?.priority ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
buildPublicSkillRuntimeConfig,
|
||||||
|
buildSkillRuntimeCatalogSummary,
|
||||||
|
} from './skill-runtime-policy.mjs';
|
||||||
|
import { buildAutoChatSkillPrefixOptions } from './chat-skills.mjs';
|
||||||
|
|
||||||
|
const h5Root = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
test('buildPublicSkillRuntimeConfig omits manifest routes when disabled', () => {
|
||||||
|
const config = buildPublicSkillRuntimeConfig(
|
||||||
|
{ router: { v2Enabled: false, manifestRoutingEnabled: false } },
|
||||||
|
h5Root,
|
||||||
|
);
|
||||||
|
assert.equal(config.routerV2Enabled, false);
|
||||||
|
assert.equal(config.manifestRoutingEnabled, false);
|
||||||
|
assert.deepEqual(config.manifestRoutes, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSkillRuntimeCatalogSummary includes product-campaign-page manifest', () => {
|
||||||
|
const catalog = buildSkillRuntimeCatalogSummary(h5Root);
|
||||||
|
const product = catalog.find((item) => item.name === 'product-campaign-page');
|
||||||
|
assert.ok(product);
|
||||||
|
assert.equal(product.hasManifest, true);
|
||||||
|
assert.ok(product.triggerKeywords.includes('带货'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAutoChatSkillPrefixOptions returns empty object when router is disabled', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildAutoChatSkillPrefixOptions({
|
||||||
|
routerV2Enabled: false,
|
||||||
|
manifestRoutingEnabled: false,
|
||||||
|
manifestRoutes: [{ skillName: 'web', keywords: ['新闻'], priority: 1, promptKey: 'web', promptVariant: null }],
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: product-campaign-page
|
||||||
|
version: 1.0.0
|
||||||
|
description: 商品宣传 / 活动页
|
||||||
|
trigger:
|
||||||
|
keywords:
|
||||||
|
- 带货
|
||||||
|
- 商品页
|
||||||
|
- 种草
|
||||||
|
- 电商页
|
||||||
|
router:
|
||||||
|
promptKey: product-campaign-page
|
||||||
|
priority: 20
|
||||||
|
executors:
|
||||||
|
- goose
|
||||||
Reference in New Issue
Block a user