Files
memind/user-memory-profile.mjs
T
john 2c91689692 feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

434 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import { resolveUserAddressName } from './user-publish.mjs';
export const USER_MEMORY_PROFILE_FILENAME = '.tkmind-profile.json';
export const USER_MEMORY_PROFILE_VERSION = 1;
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
export function buildInitialUserMemoryProfile({
userId,
displayName,
username,
slug,
now = Date.now(),
}) {
const addressName = resolveUserAddressName({ displayName, username, slug });
return {
version: USER_MEMORY_PROFILE_VERSION,
userId,
displayName: addressName,
language: 'zh-CN',
responseStyle: 'balanced',
preferences: [],
createdAt: now,
updatedAt: now,
};
}
export function resolveUserMemoryProfilePath(workspaceRoot) {
return path.join(workspaceRoot, USER_MEMORY_PROFILE_FILENAME);
}
export function loadUserMemoryProfile(workspaceRoot) {
const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
if (!fs.existsSync(profilePath)) return null;
try {
const raw = fs.readFileSync(profilePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
return parsed;
} catch {
return null;
}
}
export function ensureUserMemoryProfile(workspaceRoot, context) {
const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
const existing = loadUserMemoryProfile(workspaceRoot);
if (existing) {
const addressName = resolveUserAddressName(context);
const nextDisplayName = addressName || existing.displayName;
if (nextDisplayName && nextDisplayName !== existing.displayName) {
const updated = {
...existing,
displayName: nextDisplayName,
updatedAt: Date.now(),
};
fs.writeFileSync(profilePath, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
return updated;
}
return existing;
}
const profile = buildInitialUserMemoryProfile({
userId: context.userId,
displayName: context.displayName,
username: context.username,
slug: context.slug,
});
fs.writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
return profile;
}
export function renderMemoryStoreGuidance({ addressName }) {
const name = addressName || '用户';
return [
'## TKMind 长期记忆(L3 / memory 扩展)',
'',
`- 当前服务对象:**${name}**(仅此用户,不得与其他用户混淆)`,
'- 使用 memory 工具记录用户**明确表达**的偏好、习惯与稳定事实',
'- 记忆分类建议:',
' - `preference`:回复风格、格式、语言、称呼习惯',
' - `project`:当前项目目标、约定、技术选型(与 L2 项目记忆互补)',
' - `fact`:长期稳定、可复用的事实(如部门、常用工具)',
'- 用户说「忘记…」「不要再…」「取消偏好…」时,必须更新或删除对应记忆',
'- **禁止**记录其他用户的信息;**禁止**猜测未明确表达的偏好',
'- 与 L2 项目记忆(harness)分工:L2 记近期工作与决策摘要;L3 记可跨会话复用的个人偏好',
].join('\n');
}
function renderPreferenceLines(profile) {
const items = Array.isArray(profile?.preferences) ? profile.preferences : [];
if (items.length === 0) {
return ['- (暂无结构化偏好 — 可在对话中用 memory 工具补充)'];
}
return items.map((item) => {
if (typeof item === 'string') return `- ${item}`;
const category = item?.category ? `[${item.category}] ` : '';
const label = item?.label ?? item?.key ?? 'preference';
const value = item?.value ?? item?.content ?? '';
return `- ${category}${label}${value}`;
});
}
export function renderUserMemoryProfileForHarness(profile, context = {}) {
const addressName = resolveUserAddressName({
displayName: profile?.displayName ?? context.displayName,
username: context.username,
slug: context.slug,
});
const language = profile?.language === 'zh-CN' ? '简体中文' : (profile?.language ?? '简体中文');
const styleMap = {
concise: '简洁直接,少废话',
detailed: '详细完整,必要时展开',
balanced: '简洁务实,必要时补充细节',
};
const responseStyle = styleMap[profile?.responseStyle] ?? styleMap.balanced;
return [
'## TKMind 用户偏好画像(L3',
'',
`- 用户称呼:**${addressName}**`,
`- 界面语言:${language}`,
`- 默认回复风格:${responseStyle}`,
'',
'### 已记录的结构化偏好',
...renderPreferenceLines(profile),
].join('\n');
}
function renderStoredUserMemoryLines(memories) {
const items = Array.isArray(memories) ? memories : [];
if (items.length === 0) return ['- (暂无已沉淀的对话记忆)'];
return items.slice(0, 50).map((item) => {
const label = item?.label ? `[${item.label}] ` : '';
const text = String(item?.text ?? item?.memory_text ?? item?.memoryText ?? '').trim();
return `- ${label}${text}`;
}).filter((line) => line.trim() !== '-');
}
export function renderStoredUserMemoriesForHarness(memories) {
return [
'## TKMind 已沉淀用户记忆',
'',
'以下内容来自用户历史对话的长期记忆抽取,仅用于改善当前用户体验。',
'不要把这些内容透露给其他用户;如果用户要求忘记或删除,应遵从并更新对应记忆。',
'',
...renderStoredUserMemoryLines(memories),
].join('\n');
}
function formatDateParts(now, timezone) {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = Object.fromEntries(
formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value]),
);
return {
year: parts.year ?? '0000',
month: parts.month ?? '01',
day: parts.day ?? '01',
};
}
export function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) {
const { year, month, day } = formatDateParts(now, timezone);
const weekday = new Intl.DateTimeFormat('zh-CN', {
timeZone: timezone,
weekday: 'long',
}).format(new Date(now));
return [
'## TKMind 当前时间基准',
'',
`- 当前时区:${timezone}`,
`- 当前日期:${year}-${month}-${day}${weekday}`,
'- 回答中涉及“今天 / 明天 / 后天 / 周几”时,必须以上述日期为准继续推算,不要自行假设当前日期。',
].join('\n');
}
export function buildSessionMemoryEntries({
workingDir,
sessionPolicy,
sandboxConstraints = null,
userContext = null,
userMemories = null,
now = Date.now(),
}) {
const entries = [];
const timezone = String(
userContext?.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? DEFAULT_TIMEZONE,
).trim() || DEFAULT_TIMEZONE;
if (sandboxConstraints?.trim()) {
entries.push({
title: 'TKMind 用户空间沙箱',
content: sandboxConstraints.trim(),
});
}
const executorGuidance = renderCodeExecutorGuidance(sessionPolicy);
if (executorGuidance) {
entries.push({
title: 'TKMind 代码委托策略',
content: executorGuidance,
});
}
entries.push({
title: 'TKMind 当前时间基准',
content: renderCurrentTimeAnchor({ now, timezone }),
});
const scheduleTools = (sessionPolicy?.extensionOverrides ?? []).find((ext) => ext.name === 'sandbox-fs');
if ((scheduleTools?.available_tools ?? []).includes('schedule_create_item')) {
entries.push({
title: 'TKMind 日程写入规则',
content: [
'创建或提醒待办/日程时:',
'- 必须使用 startLocal / endLocal / remindLocal,格式 YYYY-MM-DD HH:mm(用户时区墙上时钟)。',
'- 禁止自行估算 Unix 毫秒时间戳;传错会被工具拒绝。',
'- 写入后调用 schedule_list_items 核对日期是否与用户表述一致。',
].join('\n'),
});
}
if (!hasMemoryStore(sessionPolicy)) {
return entries;
}
// Always call ensureUserMemoryProfile when userContext is available so that
// stale displayName values (e.g. UUID written before nickname was resolved)
// are healed on the next session init.
const profile = userContext?.userId
? ensureUserMemoryProfile(workingDir, userContext)
: loadUserMemoryProfile(workingDir);
const addressName = resolveUserAddressName({
displayName: profile?.displayName ?? userContext?.displayName,
username: userContext?.username,
slug: userContext?.slug,
});
entries.push({
title: 'TKMind 长期记忆规则',
content: renderMemoryStoreGuidance({ addressName }),
});
if (profile) {
entries.push({
title: 'TKMind 用户偏好画像',
content: renderUserMemoryProfileForHarness(profile, userContext ?? {}),
});
}
if (Array.isArray(userMemories) && userMemories.length > 0) {
entries.push({
title: 'TKMind 已沉淀用户记忆',
content: renderStoredUserMemoriesForHarness(userMemories),
});
}
return entries;
}
export function hasMemoryStore(sessionPolicy) {
return (sessionPolicy?.extensionOverrides ?? []).some((ext) => ext.name === 'memory');
}
function availableDelegateExecutors(sessionPolicy) {
const names = new Set((sessionPolicy?.extensionOverrides ?? []).map((ext) => ext.name));
return ['aider', 'openhands'].filter((name) => names.has(name));
}
export function resolveCodeExecutorRouting(sessionPolicy) {
const available = availableDelegateExecutors(sessionPolicy);
const configuredPreferred = String(
sessionPolicy?.policies?.code_delegate_executor ?? 'auto',
).trim();
const routing = String(sessionPolicy?.policies?.code_task_routing ?? 'balanced').trim();
const preferred =
(configuredPreferred === 'aider' || configuredPreferred === 'openhands') &&
available.includes(configuredPreferred)
? configuredPreferred
: 'auto';
const rules = [];
if (routing === 'split') {
rules.push('小范围补丁、局部修复、少文件修改优先 `aider`。');
rules.push('复杂多文件改造、仓库探索、较重的命令执行优先 `openhands`。');
} else if (routing === 'force_aider') {
rules.push('只要任务能由 `aider` 胜任,就优先统一走 `aider`。');
rules.push('只有 `aider` 明显无法胜任时才回退到 `openhands`。');
} else if (routing === 'force_openhands') {
rules.push('只要任务需要代码委托,就优先统一走 `openhands`。');
rules.push('只有任务明显更适合轻量补丁时才回退到 `aider`。');
} else {
rules.push('由 Goose 根据任务复杂度、涉及文件数、是否需要仓库探索和命令执行,在 `aider` 与 `openhands` 之间平衡选择。');
}
if (preferred !== 'auto') {
rules.unshift(`后台优先执行器:**${preferred}**。`);
} else {
rules.unshift('后台优先执行器:`auto`,由 Goose 结合任务特征决定。');
}
return {
available,
preferred,
routing,
rules,
};
}
function containsAny(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
export function suggestCodeExecutorForTask(taskText, sessionPolicy) {
const text = String(taskText ?? '').trim().toLowerCase();
if (!text) return null;
const routing = resolveCodeExecutorRouting(sessionPolicy);
if (routing.available.length === 0) return null;
const codingSignals = [
/bug|fix|debug|refactor|feature|repo|repository|code|patch|test|compile|build/,
/修复|改代码|重构|功能|仓库|代码|补丁|测试|编译|构建|多文件|命令|脚本/,
];
if (!containsAny(text, codingSignals)) {
return null;
}
let aiderScore = 0;
let openhandsScore = 0;
if (containsAny(text, [/small|minor|tiny|simple|one file|single file|quick patch/, /小改|微调|简单修复|单文件|一个文件|快速修复/])) {
aiderScore += 2;
}
if (containsAny(text, [/refactor|multi-?file|repo|repository|end-to-end|investigate|explore/, /重构|多文件|仓库级|全链路|排查|探索代码库/])) {
openhandsScore += 2;
}
if (containsAny(text, [/run|command|terminal|shell|build|compile|test suite/, /执行命令|终端|shell|构建|编译|整套测试/])) {
openhandsScore += 1;
}
if (containsAny(text, [/rename|edit|patch|tweak/, /修改一下|补丁|小范围调整|局部编辑/])) {
aiderScore += 1;
}
if (routing.routing === 'split') {
aiderScore += 1;
openhandsScore += 1;
} else if (routing.routing === 'force_aider') {
aiderScore += 3;
} else if (routing.routing === 'force_openhands') {
openhandsScore += 3;
}
if (routing.preferred === 'aider') aiderScore += 2;
if (routing.preferred === 'openhands') openhandsScore += 2;
let suggested = null;
if (openhandsScore > aiderScore && routing.available.includes('openhands')) {
suggested = 'openhands';
} else if (aiderScore > openhandsScore && routing.available.includes('aider')) {
suggested = 'aider';
} else if (routing.preferred !== 'auto' && routing.available.includes(routing.preferred)) {
suggested = routing.preferred;
} else if (routing.routing === 'split' && routing.available.includes('aider') && routing.available.includes('openhands')) {
suggested = openhandsScore >= aiderScore ? 'openhands' : 'aider';
} else {
suggested = routing.available[0] ?? null;
}
if (!suggested) return null;
const reason =
suggested === 'openhands'
? '任务看起来更像复杂多文件改造、仓库探索或需要更多命令执行。'
: '任务看起来更像局部补丁、小范围修复或较轻量的代码修改。';
return {
suggestedExecutor: suggested,
reason,
routing,
};
}
export function buildTaskRoutingAgentText(taskText, sessionPolicy) {
const suggestion = suggestCodeExecutorForTask(taskText, sessionPolicy);
if (!suggestion) return String(taskText ?? '').trim();
return [
'【TKMind 路由提示】以下提示仅用于执行器编排,不要向用户复述。',
`当前代码任务建议优先委托给:${suggestion.suggestedExecutor}`,
`原因:${suggestion.reason}`,
'若首选执行器当前不可用或明显不适合,可回退到另一个已授权执行器,并在最终回复里简述原因。',
'',
String(taskText ?? '').trim(),
].join('\n');
}
export function renderCodeExecutorGuidance(sessionPolicy) {
const { available, preferred, routing, rules } = resolveCodeExecutorRouting(sessionPolicy);
if (available.length === 0) return '';
const availableText = available.join(' / ');
const lines = [
'## TKMind 代码委托执行器路由',
'',
`- 当前可用的代码委托执行器:${availableText}`,
];
if (preferred === 'aider' || preferred === 'openhands') {
lines.push(`- 后台策略要求:多文件编码任务优先使用 **${preferred}**。`);
lines.push(
'- 如果首选执行器当前不可用、无法完成任务、或任务明显更适合另一执行器,可回退到另一个已授权执行器,并在回复中说明原因。',
);
} else {
lines.push('- 后台策略要求:由 Goose 根据任务复杂度在已授权执行器中自动选择。');
}
lines.push(`- 任务路由模式:\`${routing}\``);
for (const rule of rules) {
lines.push(`- ${rule}`);
}
lines.push('- 若当前任务不需要委托编码执行器,可继续直接使用 Goose 自身工具完成。');
return lines.join('\n');
}