feat(persona): 時區改為+08:00等值轉換、補機密遮蔽,並接上shared共用規範
依 todo.md 執行的規範治理專案: - persona-lib.mjs 的 iso()/nowIso() 改為輸出 +08:00 offset(等值瞬間轉換, 非加8小時),新增 nowDisplay()(人讀顯示格式)與 todayTaipei()(台北曆日 日期鍵),修正 11 處原本用 UTC 字串切片產生日期鍵的錯誤用法;新增 redactSecrets() 並套用到 persona-gitea.mjs 的 Gitea 錯誤路徑,避免機密 外洩。 - 新增一次性遷移腳本 scripts/migrate-tz.mjs(備份/dry-run/冪等/衝突偵測 中止),供之後有真實人格資料需要轉換時使用。 - persona 13 個 skill 與 AGENTS.md 接上 /jsc-shared 共用規範(spec-output、 spec-script-path、spec-subagent、spec-gitea、spec-git-safety、 spec-skill-invocation 等),不再各自重抄,且完全未變動人格行為本身的規範 (情緒模型、記憶衰減、劇場模式、鎖與隔離機制等逐字保留)。 - selftest 全數通過(788/788,含本次新增的時區邊界/冪等性測試)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+68
-9
@@ -129,7 +129,20 @@ export function setDefaultPersona(slug) {
|
||||
// 時間與檔案 IO
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const iso = (date = new Date()) => new Date(Math.floor(date.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
|
||||
const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 產生帶 `+08:00`(Asia/Taipei)offset 的 ISO 字串,例如 `2026-08-11T10:10:13+08:00`。
|
||||
* 這是等值時區轉換:輸出代表跟輸入 `date` 完全相同的瞬間(epoch 不變),
|
||||
* 只是把「顯示的牆上時間」換成台北時區,而不是原本 UTC 的 `Z`。
|
||||
* `parseIso()`/`new Date()` 都能正確解析回相同的 epoch,既有時間差運算不受影響。
|
||||
*/
|
||||
export const iso = (date = new Date()) => {
|
||||
const truncated = new Date(Math.floor(date.getTime() / 1000) * 1000);
|
||||
const shifted = new Date(truncated.getTime() + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}+08:00`;
|
||||
};
|
||||
export const nowIso = () => iso(new Date());
|
||||
|
||||
export function parseIso(value) {
|
||||
@@ -138,6 +151,26 @@ export function parseIso(value) {
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 給人看的顯示字串(不可被 `new Date()` 解析):Asia/Taipei 時區、`yyyy/MM/dd HH:mm:ss`。
|
||||
* 用途:任何要印給使用者看或寫進 log 訊息的地方,依 /jsc-shared:spec-time-log 規定的格式。
|
||||
*/
|
||||
export function nowDisplay(date = new Date()) {
|
||||
const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${shifted.getUTCFullYear()}/${pad(shifted.getUTCMonth() + 1)}/${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 該時刻依台北曆日計算的 `yyyy-MM-dd` 日期字串,取代錯誤的 `nowIso().slice(0, 10)`(那是 UTC 當天)。
|
||||
* 例如 UTC `2026-08-10T16:30:00Z`(台北 `2026-08-11 00:30`)要回傳 `2026-08-11`。
|
||||
*/
|
||||
export function todayTaipei(date = new Date()) {
|
||||
const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function ageSeconds(value) {
|
||||
const dt = parseIso(value);
|
||||
if (!dt) return Infinity;
|
||||
@@ -686,7 +719,7 @@ export function loadDayMood(slug) {
|
||||
const data = readJson(dayMoodPath(slug), {}) ?? {};
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
||||
return {
|
||||
date: typeof data.date === "string" ? data.date : nowIso().slice(0, 10),
|
||||
date: typeof data.date === "string" ? data.date : todayTaipei(),
|
||||
valence: clamp(num(data.valence), -100, 100),
|
||||
arousal: clamp(num(data.arousal), 0, 100),
|
||||
samples: Math.max(0, Math.floor(num(data.samples))),
|
||||
@@ -701,7 +734,7 @@ export function loadDayMood(slug) {
|
||||
export function updateDayMood(slug, state = null) {
|
||||
return withFileLock(dayMoodPath(slug), () => {
|
||||
const day = loadDayMood(slug);
|
||||
const today = nowIso().slice(0, 10);
|
||||
const today = todayTaipei();
|
||||
if (day.date !== today) {
|
||||
day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10;
|
||||
day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10;
|
||||
@@ -724,7 +757,7 @@ export function sleepDayMood(slug) {
|
||||
const day = loadDayMood(slug);
|
||||
day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10;
|
||||
day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10;
|
||||
day.date = nowIso().slice(0, 10);
|
||||
day.date = todayTaipei();
|
||||
day.samples = 0;
|
||||
day.updated_at = nowIso();
|
||||
writeJson(dayMoodPath(slug), day);
|
||||
@@ -2759,7 +2792,7 @@ export function writeLongTermMemory(slug, {
|
||||
const file = path.join(longTermDir(slug), `${name}.md`);
|
||||
let existing = {};
|
||||
if (fs.existsSync(file)) [existing] = parseFrontMatter(fs.readFileSync(file, "utf8"));
|
||||
const today = nowIso().slice(0, 10);
|
||||
const today = todayTaipei();
|
||||
const stamp = contextStamp(slug);
|
||||
// `gist` 明寫時,`detail` 沒給就是**沒有細節**——退回整份 body 會讓主旨之外
|
||||
// 再抄一次全文,那是把「衰減先吃細節」這件事整個抵銷掉。
|
||||
@@ -2844,7 +2877,7 @@ export function writeChapter(slug, { name, title = "", from = "", to = "", body
|
||||
"---",
|
||||
`name: ${key}`,
|
||||
`title: ${fm(title || existing.title || name).slice(0, 120)}`,
|
||||
`from: ${fm(from) || existing.from || nowIso().slice(0, 10)}`,
|
||||
`from: ${fm(from) || existing.from || todayTaipei()}`,
|
||||
// `to` 空著=還在這一段裡。所以沒有值就**不寫這一行**,不要寫成 `to: `。
|
||||
...((fm(to) || existing.to) ? [`to: ${fm(to) || existing.to}`] : []),
|
||||
`updated_at: ${nowIso()}`,
|
||||
@@ -2900,7 +2933,7 @@ export function currentChapter(slug) {
|
||||
}
|
||||
|
||||
/** 收掉一段(寫上 `to`)。找不到那一段回 null。 */
|
||||
export function closeChapter(slug, name, at = nowIso().slice(0, 10)) {
|
||||
export function closeChapter(slug, name, at = todayTaipei()) {
|
||||
const key = slugify(name);
|
||||
if (!fs.existsSync(chapterPath(slug, key))) return null;
|
||||
return writeChapter(slug, { name: key, to: at });
|
||||
@@ -2927,7 +2960,7 @@ export function rebuildIndex(slug) {
|
||||
const lines = [
|
||||
"# 長期記憶索引",
|
||||
"",
|
||||
`<!-- 由 persona.mjs 自動產生,最後更新 ${nowIso()};一則記憶一行 -->`,
|
||||
`<!-- 由 persona.mjs 自動產生,最後更新 ${nowDisplay()};一則記憶一行 -->`,
|
||||
"",
|
||||
];
|
||||
const sorted = [...entries].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
|
||||
@@ -3244,7 +3277,7 @@ export function markShortTermReviewed(slug, { indexes = null, until = null, prom
|
||||
*/
|
||||
export function touchRecall(slug, names) {
|
||||
const wanted = new Set(names);
|
||||
const today = nowIso().slice(0, 10);
|
||||
const today = todayTaipei();
|
||||
for (const meta of longTermEntries(slug)) {
|
||||
if (!wanted.has(meta._name)) continue;
|
||||
let text;
|
||||
@@ -6104,6 +6137,32 @@ export function injectSafeLine(text, limit = 0) {
|
||||
return limit > 0 ? one.slice(0, limit) : one;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 機密遮蔽(port 自 doc/scripts/worklog/transcript.py 的 REDACT_PATTERNS,
|
||||
// 為 /jsc-shared:spec-gitea『機密遮蔽實作』章節的來源依據;本檔僅共用定義,
|
||||
// 套用地點見 A0-4:persona 側 Gitea 錯誤遮蔽)
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
const REDACT_PATTERNS = [
|
||||
[/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"], // URL 內嵌憑證 user:token@
|
||||
[/\b[0-9a-f]{40}\b/g, "***"], // Gitea 40 字元 token
|
||||
[/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "***"], // GitHub token
|
||||
[/\bsk-[A-Za-z0-9\-_]{16,}\b/g, "***"], // API key
|
||||
[/\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+/gi, "$1=***"],
|
||||
[/Authorization:\s*(token|bearer)\s+\S+/gi, "Authorization: $1 ***"],
|
||||
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "***"], // Email
|
||||
[/\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b/g, "***"], // 台灣手機
|
||||
[/\b[A-Z][12]\d{8}\b/g, "***"], // 身分證字號
|
||||
];
|
||||
|
||||
/** 對文字依序套用全部機密遮蔽規則,回傳遮蔽後的結果。 */
|
||||
// 本檔遮蔽規則對應 shared/scripts/lib/redact-patterns.json(經 /jsc-shared:spec-gitea 收斂)
|
||||
export function redactSecrets(text) {
|
||||
let out = String(text ?? "");
|
||||
for (const [pattern, replacement] of REDACT_PATTERNS) out = out.replace(pattern, replacement);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function identityFields(slug) {
|
||||
const fields = {};
|
||||
let text;
|
||||
|
||||
Reference in New Issue
Block a user