fix(agent): inject Asia/Shanghai time anchor on 0630007 line
Agent replies were reporting UTC time because 0630007-memind never injected TKMind 当前时间基准 into session memory. Port the time anchor from the 0630004 work only—no miniapp branch changes—and add a 103 rollback script. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
# 103 回退说明 — Agent 时间基准修复(2026-07-01)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
- 分支:`0630007-agent-time-fix`(从 `0630007-memind` 切出,**不含** miniapp / 0630006 改动)
|
||||||
|
- 修复:向 Agent 注入 `TKMind 当前时间基准`(Asia/Shanghai),避免回复里报 UTC 时间
|
||||||
|
|
||||||
|
## 发布前 103 状态(可回退目标)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| release_id | `20260701-075736-16828a5` |
|
||||||
|
| git_head | `16828a58f7fc79d7b9a174ffa87f5579894798fc` |
|
||||||
|
| git_branch | `0630007-memind` |
|
||||||
|
|
||||||
|
回退到上述 release 发布前版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/rollback-portal-runtime-prod.sh 20260701-075736-16828a5 --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
## 查看 103 上所有可回退点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/rollback-portal-runtime-prod.sh --list
|
||||||
|
```
|
||||||
|
|
||||||
|
## 回退源优先级
|
||||||
|
|
||||||
|
1. `/Users/john/Project/archives/Memind-source-before-<RELEASE_ID>/`
|
||||||
|
2. `/Users/john/Project/backups/memind/memind-full-<RELEASE_ID>-before.tar.gz`
|
||||||
|
|
||||||
|
发布脚本会自动创建上述两份备份;回退脚本优先用 archive(最快)。
|
||||||
|
|
||||||
|
## 发布后验收
|
||||||
|
|
||||||
|
1. `curl -s http://127.0.0.1:8081/api/status` → 200
|
||||||
|
2. 新建 H5 会话,问「现在几点」→ 应报 **上海时间**(非 UTC 前一天 23:xx)
|
||||||
|
3. 日志无 `User auth bootstrap failed`
|
||||||
|
|
||||||
|
## 若新 release 有问题
|
||||||
|
|
||||||
|
假设新 release_id 为 `20260701-HHMMSS-<sha>`,立即回退:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/rollback-portal-runtime-prod.sh 20260701-HHMMSS-<sha> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
失败的 live 目录会保留在:
|
||||||
|
|
||||||
|
`/Users/john/Project/archives/Memind-failed-after-<RELEASE_ID>-<timestamp>/`
|
||||||
Executable
+224
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
HOST="${STUDIO_HOST:-58.38.22.103}"
|
||||||
|
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
|
||||||
|
APP_DIR="${REMOTE_ROOT}/Memind"
|
||||||
|
BACKUP_DIR="${REMOTE_ROOT}/backups/memind"
|
||||||
|
ARCHIVE_DIR="${REMOTE_ROOT}/archives"
|
||||||
|
HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}"
|
||||||
|
PORTAL_LABEL="cn.tkmind.memind-portal"
|
||||||
|
LAUNCHD_GUI="gui/$(id -u)"
|
||||||
|
AUTO_YES=0
|
||||||
|
RELEASE_ID=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法:
|
||||||
|
bash scripts/rollback-portal-runtime-prod.sh <RELEASE_ID> [--yes]
|
||||||
|
bash scripts/rollback-portal-runtime-prod.sh --list [--limit N]
|
||||||
|
|
||||||
|
说明:
|
||||||
|
将 103 Portal 回退到指定 release 发布前的 live 目录。
|
||||||
|
|
||||||
|
RELEASE_ID 形如 20260701-075736-16828a5(与 release-portal-runtime-prod.sh 输出一致)。
|
||||||
|
|
||||||
|
回退优先级:
|
||||||
|
1. /Users/john/Project/archives/Memind-source-before-<RELEASE_ID>
|
||||||
|
2. /Users/john/Project/backups/memind/memind-full-<RELEASE_ID>-before.tar.gz
|
||||||
|
|
||||||
|
当前失败的 live 目录会移入 archive,便于事后排查。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
bash scripts/rollback-portal-runtime-prod.sh --list
|
||||||
|
bash scripts/rollback-portal-runtime-prod.sh 20260701-075736-16828a5 --yes
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--yes|-y) AUTO_YES=1 ;;
|
||||||
|
--list)
|
||||||
|
ssh -o BatchMode=yes "${HOST}" "bash -s" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
echo "=== 当前 live manifest ==="
|
||||||
|
if [[ -f /Users/john/Project/Memind/.release-manifest.txt ]]; then
|
||||||
|
cat /Users/john/Project/Memind/.release-manifest.txt
|
||||||
|
else
|
||||||
|
echo "(无 manifest)"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "=== 可回退 archive(最近 10 条)==="
|
||||||
|
ls -1dt /Users/john/Project/archives/Memind-source-before-* 2>/dev/null | head -10 || true
|
||||||
|
echo
|
||||||
|
echo "=== 可回退 full backup(最近 10 条)==="
|
||||||
|
ls -1dt /Users/john/Project/backups/memind/memind-full-*-before.tar.gz 2>/dev/null | head -10 || true
|
||||||
|
REMOTE
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z "${RELEASE_ID}" ]]; then
|
||||||
|
RELEASE_ID="$1"
|
||||||
|
else
|
||||||
|
echo "未知参数: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${RELEASE_ID}" ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
say() {
|
||||||
|
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
say "103 回退预检查"
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo rollback-ssh-ok" >/dev/null
|
||||||
|
|
||||||
|
ARCHIVED_SOURCE="${ARCHIVE_DIR}/Memind-source-before-${RELEASE_ID}"
|
||||||
|
FULL_BACKUP="${BACKUP_DIR}/memind-full-${RELEASE_ID}-before.tar.gz"
|
||||||
|
|
||||||
|
ssh -o BatchMode=yes "${HOST}" "test -d '${ARCHIVED_SOURCE}' || test -f '${FULL_BACKUP}'" || {
|
||||||
|
echo "找不到 release ${RELEASE_ID} 的回退源:" >&2
|
||||||
|
echo " ${ARCHIVED_SOURCE}" >&2
|
||||||
|
echo " ${FULL_BACKUP}" >&2
|
||||||
|
echo "可先运行: bash scripts/rollback-portal-runtime-prod.sh --list" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${AUTO_YES}" -ne 1 ]]; then
|
||||||
|
say "回退确认"
|
||||||
|
echo "目标主机: ${HOST}"
|
||||||
|
echo "回退 release: ${RELEASE_ID}"
|
||||||
|
echo "当前 live: ${APP_DIR}"
|
||||||
|
ssh -o BatchMode=yes "${HOST}" "
|
||||||
|
if [[ -d '${ARCHIVED_SOURCE}' ]]; then echo 'source=archived_source'; fi
|
||||||
|
if [[ -f '${FULL_BACKUP}' ]]; then echo 'source=full_backup'; fi
|
||||||
|
if [[ -f '${APP_DIR}/.release-manifest.txt' ]]; then echo '--- current manifest ---'; cat '${APP_DIR}/.release-manifest.txt'; fi
|
||||||
|
"
|
||||||
|
read -r -p "确认回退到 ${RELEASE_ID} 发布前版本? [y/N] " confirm </dev/tty
|
||||||
|
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "在 103 执行回退"
|
||||||
|
ssh -o BatchMode=yes "${HOST}" \
|
||||||
|
"RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' ARCHIVED_SOURCE='${ARCHIVED_SOURCE}' FULL_BACKUP='${FULL_BACKUP}' ARCHIVE_DIR='${ARCHIVE_DIR}' HEALTH_URL='${HEALTH_URL}' PORTAL_LABEL='${PORTAL_LABEL}' LAUNCHD_GUI='${LAUNCHD_GUI}' /bin/bash" <<'REMOTE_SCRIPT'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROLLBACK_TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
FAILED_LIVE="${ARCHIVE_DIR}/Memind-failed-after-${RELEASE_ID}-${ROLLBACK_TS}"
|
||||||
|
RESTORE_DIR="${APP_DIR}.rollback-${ROLLBACK_TS}"
|
||||||
|
|
||||||
|
say() {
|
||||||
|
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
say "停止 Portal"
|
||||||
|
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
|
||||||
|
lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||||
|
|
||||||
|
say "准备回退目录"
|
||||||
|
rm -rf "${RESTORE_DIR}"
|
||||||
|
mkdir -p "${RESTORE_DIR}"
|
||||||
|
|
||||||
|
if [[ -d "${ARCHIVED_SOURCE}" ]]; then
|
||||||
|
say "从 archived_source 恢复: ${ARCHIVED_SOURCE}"
|
||||||
|
cp -a "${ARCHIVED_SOURCE}/." "${RESTORE_DIR}/"
|
||||||
|
elif [[ -f "${FULL_BACKUP}" ]]; then
|
||||||
|
say "从 full backup 恢复: ${FULL_BACKUP}"
|
||||||
|
tar -xzf "${FULL_BACKUP}" -C "${RESTORE_DIR}"
|
||||||
|
else
|
||||||
|
echo "rollback source missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "归档当前失败 live 目录"
|
||||||
|
if [[ -d "${APP_DIR}" ]]; then
|
||||||
|
rm -rf "${FAILED_LIVE}"
|
||||||
|
mv "${APP_DIR}" "${FAILED_LIVE}"
|
||||||
|
fi
|
||||||
|
mv "${RESTORE_DIR}" "${APP_DIR}"
|
||||||
|
|
||||||
|
cat > "${APP_DIR}/.rollback-manifest.txt" <<EOF
|
||||||
|
rollback_at=${ROLLBACK_TS}
|
||||||
|
rolled_back_release=${RELEASE_ID}
|
||||||
|
failed_live=${FAILED_LIVE}
|
||||||
|
restored_from=$([[ -d "${ARCHIVED_SOURCE}" ]] && echo archived_source || echo full_backup)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
say "重启 Portal"
|
||||||
|
if [[ -x "${APP_DIR}/scripts/run-memind-portal-prod.sh" ]]; then
|
||||||
|
cat > "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${PORTAL_LABEL}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${APP_DIR}/scripts/run-memind-portal-prod.sh</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>${APP_DIR}</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>ThrottleInterval</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>${HOME}/Library/Logs/memind-portal.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>${HOME}/Library/Logs/memind-portal.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" >/dev/null 2>&1 || true
|
||||||
|
launchctl enable "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
|
||||||
|
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || \
|
||||||
|
nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 &
|
||||||
|
else
|
||||||
|
nohup /opt/homebrew/opt/node@24/bin/node "${APP_DIR}/server.mjs" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "健康检查"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
|
||||||
|
if [[ "${portal_code}" == "200" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
|
||||||
|
if [[ "${portal_code}" != "200" ]]; then
|
||||||
|
echo "rollback health check failed: portal=${portal_code}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "回退完成"
|
||||||
|
printf 'live_dir=%s\n' "${APP_DIR}"
|
||||||
|
printf 'failed_live=%s\n' "${FAILED_LIVE}"
|
||||||
|
printf 'rolled_back_release=%s\n' "${RELEASE_ID}"
|
||||||
|
REMOTE_SCRIPT
|
||||||
|
|
||||||
|
say "103 回退后状态"
|
||||||
|
ssh -o BatchMode=yes "${HOST}" "curl -s '${HEALTH_URL}' && echo && cat '${APP_DIR}/.rollback-manifest.txt' 2>/dev/null || true"
|
||||||
@@ -6,6 +6,16 @@ import {
|
|||||||
reconcileAgentSession,
|
reconcileAgentSession,
|
||||||
} from './session-reconcile.mjs';
|
} from './session-reconcile.mjs';
|
||||||
|
|
||||||
|
function harnessMemoryResponse(pathname) {
|
||||||
|
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
text: async () => JSON.stringify({ remembered: true, ok: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
test('extensionConfigsMatch compares available_tools', () => {
|
test('extensionConfigsMatch compares available_tools', () => {
|
||||||
const sessionExt = { name: 'developer', available_tools: ['write', 'edit'] };
|
const sessionExt = { name: 'developer', available_tools: ['write', 'edit'] };
|
||||||
const desired = { name: 'developer', available_tools: ['write', 'edit', 'shell', 'tree'] };
|
const desired = { name: 'developer', available_tools: ['write', 'edit', 'shell', 'tree'] };
|
||||||
@@ -77,6 +87,8 @@ test('reconcileAgentSession tolerates invalid working directory during resume sy
|
|||||||
text: async () => JSON.stringify({ ok: true }),
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const harness = harnessMemoryResponse(pathname);
|
||||||
|
if (harness) return harness;
|
||||||
throw new Error(`unexpected path: ${pathname}`);
|
throw new Error(`unexpected path: ${pathname}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +102,8 @@ test('reconcileAgentSession tolerates invalid working directory during resume sy
|
|||||||
'/sessions/session-1',
|
'/sessions/session-1',
|
||||||
'/agent/update_working_dir',
|
'/agent/update_working_dir',
|
||||||
'/sessions/session-1/extensions',
|
'/sessions/session-1/extensions',
|
||||||
|
'/agent/harness_remember',
|
||||||
|
'/agent/harness_bootstrap',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,6 +132,8 @@ test('reconcileAgentSession skips restart when extensions already match', async
|
|||||||
text: async () => JSON.stringify({ ok: true }),
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const harness = harnessMemoryResponse(pathname);
|
||||||
|
if (harness) return harness;
|
||||||
throw new Error(`unexpected path: ${pathname}`);
|
throw new Error(`unexpected path: ${pathname}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,7 +142,12 @@ test('reconcileAgentSession skips restart when extensions already match', async
|
|||||||
sessionPolicy: { extensionOverrides: [{ name: 'skills', available_tools: [] }] },
|
sessionPolicy: { extensionOverrides: [{ name: 'skills', available_tools: [] }] },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['/sessions/session-1', '/sessions/session-1/extensions']);
|
assert.deepEqual(calls, [
|
||||||
|
'/sessions/session-1',
|
||||||
|
'/sessions/session-1/extensions',
|
||||||
|
'/agent/harness_remember',
|
||||||
|
'/agent/harness_bootstrap',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reconcileAgentSession restarts after adding missing extensions', async () => {
|
test('reconcileAgentSession restarts after adding missing extensions', async () => {
|
||||||
@@ -151,6 +172,8 @@ test('reconcileAgentSession restarts after adding missing extensions', async ()
|
|||||||
text: async () => JSON.stringify({ ok: true }),
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const harness = harnessMemoryResponse(pathname);
|
||||||
|
if (harness) return harness;
|
||||||
throw new Error(`unexpected path: ${pathname}`);
|
throw new Error(`unexpected path: ${pathname}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,5 +187,7 @@ test('reconcileAgentSession restarts after adding missing extensions', async ()
|
|||||||
'/sessions/session-1/extensions',
|
'/sessions/session-1/extensions',
|
||||||
'/agent/add_extension',
|
'/agent/add_extension',
|
||||||
'/agent/restart',
|
'/agent/restart',
|
||||||
|
'/agent/harness_remember',
|
||||||
|
'/agent/harness_bootstrap',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resolveUserAddressName } from './user-publish.mjs';
|
|||||||
|
|
||||||
export const USER_MEMORY_PROFILE_FILENAME = '.tkmind-profile.json';
|
export const USER_MEMORY_PROFILE_FILENAME = '.tkmind-profile.json';
|
||||||
export const USER_MEMORY_PROFILE_VERSION = 1;
|
export const USER_MEMORY_PROFILE_VERSION = 1;
|
||||||
|
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
|
||||||
|
|
||||||
export function buildInitialUserMemoryProfile({
|
export function buildInitialUserMemoryProfile({
|
||||||
userId,
|
userId,
|
||||||
@@ -148,14 +149,81 @@ export function renderStoredUserMemoriesForHarness(memories) {
|
|||||||
].join('\n');
|
].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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalClockParts(now, timezone) {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
hour: Number(parts.hour ?? 0),
|
||||||
|
minute: Number(parts.minute ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimeOfDayPeriod(hour) {
|
||||||
|
const h = Number(hour);
|
||||||
|
if (h < 5) return { period: '凌晨', greeting: '你好' };
|
||||||
|
if (h < 9) return { period: '早上', greeting: '早上好' };
|
||||||
|
if (h < 12) return { period: '上午', greeting: '上午好' };
|
||||||
|
if (h < 14) return { period: '中午', greeting: '中午好' };
|
||||||
|
if (h < 18) return { period: '下午', greeting: '下午好' };
|
||||||
|
return { period: '晚上', greeting: '晚上好' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) {
|
||||||
|
const { year, month, day } = formatDateParts(now, timezone);
|
||||||
|
const { hour, minute } = formatLocalClockParts(now, timezone);
|
||||||
|
const { period, greeting } = resolveTimeOfDayPeriod(hour);
|
||||||
|
const clock = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
const weekday = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: timezone,
|
||||||
|
weekday: 'long',
|
||||||
|
}).format(new Date(now));
|
||||||
|
return [
|
||||||
|
'## TKMind 当前时间基准',
|
||||||
|
'',
|
||||||
|
`- 当前时区:${timezone}`,
|
||||||
|
`- 当前日期:${year}-${month}-${day}(${weekday})`,
|
||||||
|
`- 当前时刻:${clock}(${period})`,
|
||||||
|
`- 若需时段问候可参考:${greeting}(仅新会话开场或用户主动打招呼时使用,普通任务回复不要每条都加)`,
|
||||||
|
'- 回答中涉及“今天 / 明天 / 后天 / 周几 / 早上 / 下午 / 晚上”等时间表述时,必须以上述日期与时刻为准,禁止自行假设当前时间或使用 UTC 等其它时区。',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSessionMemoryEntries({
|
export function buildSessionMemoryEntries({
|
||||||
workingDir,
|
workingDir,
|
||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
sandboxConstraints = null,
|
sandboxConstraints = null,
|
||||||
userContext = null,
|
userContext = null,
|
||||||
userMemories = null,
|
userMemories = null,
|
||||||
|
now = Date.now(),
|
||||||
}) {
|
}) {
|
||||||
const entries = [];
|
const entries = [];
|
||||||
|
const timezone = String(
|
||||||
|
userContext?.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? DEFAULT_TIMEZONE,
|
||||||
|
).trim() || DEFAULT_TIMEZONE;
|
||||||
|
|
||||||
if (sandboxConstraints?.trim()) {
|
if (sandboxConstraints?.trim()) {
|
||||||
entries.push({
|
entries.push({
|
||||||
@@ -172,6 +240,24 @@ export function buildSessionMemoryEntries({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)) {
|
if (!hasMemoryStore(sessionPolicy)) {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
buildTaskRoutingAgentText,
|
buildTaskRoutingAgentText,
|
||||||
resolveCodeExecutorRouting,
|
resolveCodeExecutorRouting,
|
||||||
renderCodeExecutorGuidance,
|
renderCodeExecutorGuidance,
|
||||||
|
renderCurrentTimeAnchor,
|
||||||
|
resolveTimeOfDayPeriod,
|
||||||
suggestCodeExecutorForTask,
|
suggestCodeExecutorForTask,
|
||||||
buildSessionMemoryEntries,
|
buildSessionMemoryEntries,
|
||||||
ensureUserMemoryProfile,
|
ensureUserMemoryProfile,
|
||||||
@@ -77,13 +79,18 @@ test('buildSessionMemoryEntries injects sandbox, guidance, and profile', () => {
|
|||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
sandboxConstraints: '## sandbox',
|
sandboxConstraints: '## sandbox',
|
||||||
userContext: { userId: 'user-2', displayName: 'Bob', username: 'bob' },
|
userContext: { userId: 'user-2', displayName: 'Bob', username: 'bob' },
|
||||||
|
now: Date.UTC(2026, 5, 29, 5, 0, 0),
|
||||||
});
|
});
|
||||||
assert.equal(entries.length, 4);
|
assert.equal(entries.length, 5);
|
||||||
assert.equal(entries[0].title, 'TKMind 用户空间沙箱');
|
assert.equal(entries[0].title, 'TKMind 用户空间沙箱');
|
||||||
assert.equal(entries[1].title, 'TKMind 代码委托策略');
|
assert.equal(entries[1].title, 'TKMind 代码委托策略');
|
||||||
assert.match(entries[1].content, /openhands/);
|
assert.match(entries[1].content, /openhands/);
|
||||||
assert.match(entries[2].content, /长期记忆/);
|
assert.equal(entries[2].title, 'TKMind 当前时间基准');
|
||||||
assert.match(entries[3].content, /Bob/);
|
assert.match(entries[2].content, /2026-06-29(星期一)/);
|
||||||
|
assert.match(entries[2].content, /13:00(中午)/);
|
||||||
|
assert.match(entries[2].content, /若需时段问候可参考:中午好/);
|
||||||
|
assert.match(entries[3].content, /长期记忆/);
|
||||||
|
assert.match(entries[4].content, /Bob/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildSessionMemoryEntries injects stored conversation memories', () => {
|
test('buildSessionMemoryEntries injects stored conversation memories', () => {
|
||||||
@@ -94,11 +101,57 @@ test('buildSessionMemoryEntries injects stored conversation memories', () => {
|
|||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
userContext: { userId: 'user-3', displayName: 'Carol', username: 'carol' },
|
userContext: { userId: 'user-3', displayName: 'Carol', username: 'carol' },
|
||||||
userMemories: [{ label: 'interest', text: '用户关注 AI 产品设计' }],
|
userMemories: [{ label: 'interest', text: '用户关注 AI 产品设计' }],
|
||||||
|
now: Date.UTC(2026, 5, 29, 5, 0, 0),
|
||||||
});
|
});
|
||||||
assert.equal(entries.at(-1).title, 'TKMind 已沉淀用户记忆');
|
assert.equal(entries.at(-1).title, 'TKMind 已沉淀用户记忆');
|
||||||
assert.match(entries.at(-1).content, /AI 产品设计/);
|
assert.match(entries.at(-1).content, /AI 产品设计/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildSessionMemoryEntries injects time anchor even without memory store', () => {
|
||||||
|
const sessionPolicy = buildAgentExtensionPolicy({
|
||||||
|
...DEFAULT_USER_CAPABILITIES,
|
||||||
|
memory_store: false,
|
||||||
|
});
|
||||||
|
const entries = buildSessionMemoryEntries({
|
||||||
|
workingDir: '/tmp/unused',
|
||||||
|
sessionPolicy,
|
||||||
|
now: Date.UTC(2026, 5, 29, 5, 0, 0),
|
||||||
|
});
|
||||||
|
assert.equal(entries.length, 1);
|
||||||
|
assert.equal(entries[0].title, 'TKMind 当前时间基准');
|
||||||
|
assert.match(entries[0].content, /Asia\/Shanghai/);
|
||||||
|
assert.match(entries[0].content, /13:00(中午)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderCurrentTimeAnchor formats Shanghai weekday from exact date', () => {
|
||||||
|
const text = renderCurrentTimeAnchor({
|
||||||
|
now: Date.UTC(2026, 5, 29, 5, 0, 0),
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
});
|
||||||
|
assert.match(text, /2026-06-29(星期一)/);
|
||||||
|
assert.match(text, /Asia\/Shanghai/);
|
||||||
|
assert.match(text, /13:00(中午)/);
|
||||||
|
assert.match(text, /若需时段问候可参考:中午好/);
|
||||||
|
assert.match(text, /普通任务回复不要每条都加/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderCurrentTimeAnchor uses local morning greeting at 07:03 Shanghai', () => {
|
||||||
|
const text = renderCurrentTimeAnchor({
|
||||||
|
now: Date.UTC(2026, 5, 29, 23, 3, 0),
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
});
|
||||||
|
assert.match(text, /2026-06-30(星期二)/);
|
||||||
|
assert.match(text, /07:03(早上)/);
|
||||||
|
assert.match(text, /若需时段问候可参考:早上好/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTimeOfDayPeriod maps hours to greeting labels', () => {
|
||||||
|
assert.deepEqual(resolveTimeOfDayPeriod(2), { period: '凌晨', greeting: '你好' });
|
||||||
|
assert.deepEqual(resolveTimeOfDayPeriod(7), { period: '早上', greeting: '早上好' });
|
||||||
|
assert.deepEqual(resolveTimeOfDayPeriod(10), { period: '上午', greeting: '上午好' });
|
||||||
|
assert.deepEqual(resolveTimeOfDayPeriod(20), { period: '晚上', greeting: '晚上好' });
|
||||||
|
});
|
||||||
|
|
||||||
test('renderMemoryStoreGuidance scopes memory to one user', () => {
|
test('renderMemoryStoreGuidance scopes memory to one user', () => {
|
||||||
const text = renderMemoryStoreGuidance({ addressName: '小陈' });
|
const text = renderMemoryStoreGuidance({ addressName: '小陈' });
|
||||||
assert.match(text, /小陈/);
|
assert.match(text, /小陈/);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { developerToolsFromPolicy } from './capabilities.mjs';
|
|||||||
import { mergeMessageContent } from './message-stream.mjs';
|
import { mergeMessageContent } from './message-stream.mjs';
|
||||||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||||||
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
||||||
|
import { renderCurrentTimeAnchor } from './user-memory-profile.mjs';
|
||||||
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
||||||
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
|
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
|
||||||
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
||||||
@@ -1071,10 +1072,13 @@ function buildWechatAgentPrompt(intent) {
|
|||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: '';
|
: '';
|
||||||
|
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||||||
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
|
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
|
||||||
? [
|
? [
|
||||||
'【日程技能要求】这条消息涉及待办、提醒或日程。',
|
'【日程技能要求】这条消息涉及待办、提醒或日程。',
|
||||||
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
|
'开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。',
|
||||||
|
'写入工具时优先使用 startLocal / endLocal / remindLocal(YYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。',
|
||||||
|
renderCurrentTimeAnchor({ timezone: scheduleTimezone }),
|
||||||
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
|
'只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。',
|
||||||
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
|
intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '',
|
||||||
'',
|
'',
|
||||||
|
|||||||
Reference in New Issue
Block a user