feat(role): 新增角色記憶預算與心理學記憶型態 #9
Executable
+727
@@ -0,0 +1,727 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ==============================================================================
|
||||||
|
// 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
|
||||||
|
// inbox,(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理
|
||||||
|
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
|
||||||
|
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
|
||||||
|
// 更新時間:2026/07/28 12:21:11
|
||||||
|
// 相依:Node.js 標準庫。
|
||||||
|
// 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const zlib = require("zlib");
|
||||||
|
|
||||||
|
const CATEGORIES = ["important", "interest", "news", "skill", "daily", "other"];
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
important: "重要",
|
||||||
|
interest: "興趣",
|
||||||
|
news: "新知",
|
||||||
|
skill: "技能",
|
||||||
|
daily: "日常",
|
||||||
|
other: "其他",
|
||||||
|
};
|
||||||
|
const LABEL_TO_CATEGORY = Object.fromEntries(Object.entries(CATEGORY_LABELS).map(([key, value]) => [value, key]));
|
||||||
|
|
||||||
|
const FULL_CATEGORIES = ["important", "interest"];
|
||||||
|
const DIGEST_CATEGORIES = ["skill", "news", "daily", "other"];
|
||||||
|
const FORGET_RULES = { daily: [14, 1], other: [7, 1] };
|
||||||
|
const DEFAULT_PRIORITY = { important: 5, interest: 4, skill: 4, news: 3, daily: 2, other: 1 };
|
||||||
|
|
||||||
|
const SLEEP_BATCH = 60;
|
||||||
|
const COLLECT_LIMIT = 12000;
|
||||||
|
const EXISTING_INDEX_LIMIT = 120;
|
||||||
|
const CONTENT_LIMIT = 1200;
|
||||||
|
const DEFAULT_LOAD_LIMIT = 4000;
|
||||||
|
const DEFAULT_FULL_MIN_PRIORITY = 4;
|
||||||
|
const DEFAULT_DIGEST_MIN_PRIORITY = 3;
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(0, "utf8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(value) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function taipeiDate() {
|
||||||
|
return new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowStamp() {
|
||||||
|
const d = taipeiDate();
|
||||||
|
return `${d.getUTCFullYear()}/${pad(d.getUTCMonth() + 1)}/${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowEpoch() {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStamp(value) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) return null;
|
||||||
|
const m = value.trim().match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, y, mo, d, h, mi, s] = m.map(Number);
|
||||||
|
const ms = Date.UTC(y, mo - 1, d, h - 8, mi, s);
|
||||||
|
return Number.isNaN(ms) ? null : new Date(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryRoot(role) {
|
||||||
|
const base = process.env.ROLE_MEMORY_HOME || path.join(process.env.HOME || "", ".memory");
|
||||||
|
return path.join(base, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLayout(role) {
|
||||||
|
const root = memoryRoot(role);
|
||||||
|
for (const sub of ["inbox", "archive/raw", "archive/forgotten", ...CATEGORIES]) {
|
||||||
|
fs.mkdirSync(path.join(root, sub), { recursive: true });
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statePath(role) {
|
||||||
|
return path.join(memoryRoot(role), "state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState(role) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(statePath(role), "utf8"));
|
||||||
|
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeState(role, patch) {
|
||||||
|
const state = { ...readState(role), ...patch };
|
||||||
|
ensureLayout(role);
|
||||||
|
fs.writeFileSync(statePath(role), JSON.stringify(state, null, 2), "utf8");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCategory(value) {
|
||||||
|
const raw = String(value || "").trim().toLowerCase();
|
||||||
|
if (CATEGORIES.includes(raw)) return raw;
|
||||||
|
return LABEL_TO_CATEGORY[String(value || "").trim()] || "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePush(items, value, fold = false) {
|
||||||
|
const text = String(value || "").trim().replace(/^[#\[]+|[\]]+$/g, "").trim();
|
||||||
|
if (!text) return;
|
||||||
|
const exists = fold
|
||||||
|
? items.some((item) => item.toLowerCase() === text.toLowerCase())
|
||||||
|
: items.includes(text);
|
||||||
|
if (!exists) items.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(value) {
|
||||||
|
const parts = Array.isArray(value) ? value.map(String) : typeof value === "string" ? value.split(/[,、|]/) : [];
|
||||||
|
const tags = [];
|
||||||
|
for (const part of parts) uniquePush(tags, part, true);
|
||||||
|
return tags.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeList(value, limit = 8) {
|
||||||
|
const parts = Array.isArray(value) ? value.map(String) : typeof value === "string" ? value.split(/[,、|]/) : [];
|
||||||
|
const items = [];
|
||||||
|
for (const part of parts) uniquePush(items, part, false);
|
||||||
|
return items.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriority(value, category = "other") {
|
||||||
|
const fallback = DEFAULT_PRIORITY[normalizeCategory(category)] || 1;
|
||||||
|
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
|
||||||
|
const priority = Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
return Math.max(1, Math.min(5, priority));
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneLine(value, limit = 120) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim().slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpMemory(meta, content) {
|
||||||
|
const lines = ["---"];
|
||||||
|
for (const key of [
|
||||||
|
"id",
|
||||||
|
"category",
|
||||||
|
"summary",
|
||||||
|
"tags",
|
||||||
|
"priority",
|
||||||
|
"relevance",
|
||||||
|
"links",
|
||||||
|
"sleep_stage",
|
||||||
|
"created",
|
||||||
|
"updated",
|
||||||
|
"last_replayed",
|
||||||
|
"hits",
|
||||||
|
"sources",
|
||||||
|
]) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue;
|
||||||
|
let value = meta[key];
|
||||||
|
if (Array.isArray(value)) value = `[${value.map(String).join(", ")}]`;
|
||||||
|
lines.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
lines.push("---", "", String(content || "").trim(), "");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMemory(filePath) {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return [null, ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = { path: filePath, hits: 0, tags: [] };
|
||||||
|
let body = raw;
|
||||||
|
if (raw.startsWith("---")) {
|
||||||
|
const parts = raw.split("---");
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
body = parts.slice(2).join("---");
|
||||||
|
for (const line of parts[1].split(/\r?\n/)) {
|
||||||
|
if (!line.includes(":")) continue;
|
||||||
|
const idx = line.indexOf(":");
|
||||||
|
const key = line.slice(0, idx).trim();
|
||||||
|
const value = line.slice(idx + 1).trim();
|
||||||
|
if (key === "tags" || key === "sources") meta[key] = normalizeTags(value.replace(/^\[|\]$/g, ""));
|
||||||
|
else if (key === "relevance" || key === "links") meta[key] = normalizeList(value.replace(/^\[|\]$/g, ""));
|
||||||
|
else if (key === "hits" || key === "priority") meta[key] = /^\d+$/.test(value) ? Number.parseInt(value, 10) : 0;
|
||||||
|
else meta[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta.id ||= path.basename(filePath, ".md");
|
||||||
|
meta.summary ||= "";
|
||||||
|
meta.created ||= "";
|
||||||
|
meta.updated ||= meta.created || "";
|
||||||
|
meta.relevance ||= [];
|
||||||
|
meta.links ||= [];
|
||||||
|
meta.priority = normalizePriority(meta.priority, meta.category || "other");
|
||||||
|
return [meta, body.trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function newId(seed) {
|
||||||
|
const digest = crypto.createHash("sha1").update(seed, "utf8").digest("hex").slice(0, 6);
|
||||||
|
const d = taipeiDate();
|
||||||
|
const stamp = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
|
||||||
|
return `${stamp}-${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMemories(role, category) {
|
||||||
|
const directory = path.join(memoryRoot(role), category);
|
||||||
|
if (!fs.existsSync(directory)) return [];
|
||||||
|
const items = [];
|
||||||
|
for (const name of fs.readdirSync(directory).sort()) {
|
||||||
|
if (!name.endsWith(".md")) continue;
|
||||||
|
const [meta, content] = loadMemory(path.join(directory, name));
|
||||||
|
if (!meta) continue;
|
||||||
|
meta.category = category;
|
||||||
|
items.push([meta, content]);
|
||||||
|
}
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const am = a[0];
|
||||||
|
const bm = b[0];
|
||||||
|
const av = [normalizePriority(am.priority, category), am.links?.length ? 1 : 0, am.updated || ""];
|
||||||
|
const bv = [normalizePriority(bm.priority, category), bm.links?.length ? 1 : 0, bm.updated || ""];
|
||||||
|
for (let i = 0; i < av.length; i += 1) {
|
||||||
|
if (av[i] < bv[i]) return 1;
|
||||||
|
if (av[i] > bv[i]) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listInbox(role) {
|
||||||
|
const directory = path.join(memoryRoot(role), "inbox");
|
||||||
|
if (!fs.existsSync(directory)) return [];
|
||||||
|
const items = [];
|
||||||
|
for (const name of fs.readdirSync(directory).sort()) {
|
||||||
|
if (!name.endsWith(".md")) continue;
|
||||||
|
const [meta, content] = loadMemory(path.join(directory, name));
|
||||||
|
if (meta) items.push([meta, content]);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMemory(role, memoryId) {
|
||||||
|
for (const category of CATEGORIES) {
|
||||||
|
const filePath = path.join(memoryRoot(role), category, `${memoryId}.md`);
|
||||||
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
const [meta, content] = loadMemory(filePath);
|
||||||
|
if (meta) {
|
||||||
|
meta.category = category;
|
||||||
|
return [meta, content];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [null, ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryHint(meta) {
|
||||||
|
const relevance = (meta.relevance || []).join("、") || "-";
|
||||||
|
const links = (meta.links || []).join("、") || "-";
|
||||||
|
return `優先度:${normalizePriority(meta.priority, meta.category)};關聯:${relevance};連結:${links}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveFile(filePath, destinationDir) {
|
||||||
|
fs.mkdirSync(destinationDir, { recursive: true });
|
||||||
|
const target = path.join(destinationDir, `${path.basename(filePath)}.gz`);
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath);
|
||||||
|
fs.writeFileSync(target, zlib.gzipSync(raw));
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_PATTERN = /^\s*(CATEGORY|SUMMARY|TAGS|PRIORITY|RELEVANCE|CONTENT)\s*[::]\s*(.*)$/i;
|
||||||
|
|
||||||
|
function parseCapture(text) {
|
||||||
|
let category = "";
|
||||||
|
let summary = "";
|
||||||
|
let priority = null;
|
||||||
|
let tags = [];
|
||||||
|
let relevance = [];
|
||||||
|
const contentLines = [];
|
||||||
|
let inContent = false;
|
||||||
|
for (const line of String(text || "").split(/\r?\n/)) {
|
||||||
|
const match = line.match(FIELD_PATTERN);
|
||||||
|
if (match && !(inContent && match[1].toUpperCase() !== "CONTENT")) {
|
||||||
|
const field = match[1].toUpperCase();
|
||||||
|
const value = match[2];
|
||||||
|
if (field === "CATEGORY") category = value;
|
||||||
|
else if (field === "SUMMARY") summary = value;
|
||||||
|
else if (field === "TAGS") tags = normalizeTags(value);
|
||||||
|
else if (field === "PRIORITY") priority = value;
|
||||||
|
else if (field === "RELEVANCE") relevance = normalizeList(value);
|
||||||
|
else if (field === "CONTENT") {
|
||||||
|
inContent = true;
|
||||||
|
if (value.trim()) contentLines.push(value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inContent) contentLines.push(line);
|
||||||
|
}
|
||||||
|
return { category, summary, tags, priority, relevance, content: contentLines.join("\n").trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdWrite(args) {
|
||||||
|
const parsed = parseCapture(readStdin());
|
||||||
|
if (!parsed.content && !parsed.summary) return 1;
|
||||||
|
const content = (parsed.content || parsed.summary).slice(0, CONTENT_LIMIT);
|
||||||
|
const stamp = nowStamp();
|
||||||
|
const meta = {
|
||||||
|
id: newId(content + stamp),
|
||||||
|
category: normalizeCategory(parsed.category),
|
||||||
|
summary: oneLine(parsed.summary) || oneLine(content),
|
||||||
|
tags: parsed.tags,
|
||||||
|
priority: normalizePriority(parsed.priority, parsed.category),
|
||||||
|
relevance: parsed.relevance.length ? parsed.relevance : ["inbox"],
|
||||||
|
links: [],
|
||||||
|
sleep_stage: "encoding",
|
||||||
|
created: stamp,
|
||||||
|
updated: stamp,
|
||||||
|
hits: 1,
|
||||||
|
};
|
||||||
|
if (args.project) meta.sources = [args.project];
|
||||||
|
ensureLayout(args.role);
|
||||||
|
fs.writeFileSync(path.join(memoryRoot(args.role), "inbox", `${meta.id}.md`), dumpMemory(meta, content), "utf8");
|
||||||
|
process.stdout.write(meta.id);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdSeed(args) {
|
||||||
|
const raw = readStdin().trim();
|
||||||
|
if (!raw && !args.summary) return 1;
|
||||||
|
const content = (raw || args.summary).slice(0, CONTENT_LIMIT);
|
||||||
|
const stamp = nowStamp();
|
||||||
|
const category = normalizeCategory(args.category);
|
||||||
|
const meta = {
|
||||||
|
id: newId(content + args.summary + stamp),
|
||||||
|
category,
|
||||||
|
summary: oneLine(args.summary) || oneLine(content),
|
||||||
|
tags: normalizeTags(args.tags),
|
||||||
|
priority: normalizePriority(args.priority, category),
|
||||||
|
relevance: normalizeList(args.relevance).length ? normalizeList(args.relevance) : ["explicit", "background"],
|
||||||
|
links: [],
|
||||||
|
sleep_stage: "seed",
|
||||||
|
created: stamp,
|
||||||
|
updated: stamp,
|
||||||
|
hits: 1,
|
||||||
|
};
|
||||||
|
if (args.source) meta.sources = [args.source];
|
||||||
|
ensureLayout(args.role);
|
||||||
|
fs.writeFileSync(path.join(memoryRoot(args.role), category, `${meta.id}.md`), dumpMemory(meta, content), "utf8");
|
||||||
|
process.stdout.write(meta.id);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdLoad(args) {
|
||||||
|
const blocks = [];
|
||||||
|
for (const category of FULL_CATEGORIES) {
|
||||||
|
const lines = [`### ${CATEGORY_LABELS[category]}記憶(全文)`];
|
||||||
|
for (const [meta, content] of listMemories(args.role, category)) {
|
||||||
|
if (normalizePriority(meta.priority, category) < args.fullMinPriority) continue;
|
||||||
|
const tags = (meta.tags || []).join("、") || "無標籤";
|
||||||
|
lines.push(`- **${meta.summary || "(無總結)"}**(標籤:${tags};${memoryHint(meta)})`);
|
||||||
|
for (const line of content.split(/\r?\n/)) {
|
||||||
|
if (line.trim()) lines.push(` ${line.trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.length > 1) blocks.push(lines.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const digestLines = [];
|
||||||
|
for (const category of DIGEST_CATEGORIES) {
|
||||||
|
const items = listMemories(args.role, category);
|
||||||
|
if (!items.length) continue;
|
||||||
|
digestLines.push(`### ${CATEGORY_LABELS[category]}記憶(總結)`);
|
||||||
|
for (const [meta] of items) {
|
||||||
|
const priority = normalizePriority(meta.priority, category);
|
||||||
|
if (priority < args.digestMinPriority && !(meta.links || []).length) continue;
|
||||||
|
const tags = (meta.tags || []).join("、") || "無標籤";
|
||||||
|
digestLines.push(`- ${meta.summary || "(無總結)"}(標籤:${tags};${memoryHint(meta)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (digestLines.length) blocks.push(digestLines.join("\n"));
|
||||||
|
|
||||||
|
const digest = readState(args.role).last_sleep_digest;
|
||||||
|
if (digest) blocks.push(`> 上次睡眠摘要:${digest}`);
|
||||||
|
|
||||||
|
const pending = listInbox(args.role).length;
|
||||||
|
if (pending) blocks.push(`> 尚有 ${pending} 則未整理記憶,將於下次睡眠時段歸檔。`);
|
||||||
|
if (!blocks.length) return 1;
|
||||||
|
|
||||||
|
let text = blocks.join("\n\n");
|
||||||
|
if (text.length > args.limit) {
|
||||||
|
text = `${text.slice(0, args.limit)}\n\n> (記憶內容超過 ${args.limit} 字元預算已截斷;可用 ROLE_LOAD_LIMIT 調整,完整記憶仍保存在磁碟)`;
|
||||||
|
}
|
||||||
|
process.stdout.write(text);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdCollect(args) {
|
||||||
|
const batchSize = Math.max(1, args.batch);
|
||||||
|
const collectLimit = Math.max(2000, args.limit);
|
||||||
|
const existingLimit = Math.max(0, args.existingLimit);
|
||||||
|
const inbox = listInbox(args.role).slice(0, batchSize);
|
||||||
|
if (!inbox.length) return 1;
|
||||||
|
|
||||||
|
const lines = ["=== INBOX(待整理,每則以 id 標識)==="];
|
||||||
|
for (const [meta, content] of inbox) {
|
||||||
|
lines.push(`--- id: ${meta.id} | 時間: ${meta.created || "-"} ---`);
|
||||||
|
lines.push(`初判分類: ${CATEGORY_LABELS[meta.category || "other"] || "其他"}`);
|
||||||
|
lines.push(`初判總結: ${meta.summary || ""}`);
|
||||||
|
lines.push(`初判標籤: ${(meta.tags || []).join("、") || "無"}`);
|
||||||
|
lines.push(`初判優先度: ${normalizePriority(meta.priority, meta.category || "other")}`);
|
||||||
|
lines.push(`初判關聯: ${(meta.relevance || []).join("、") || "-"}`);
|
||||||
|
lines.push("內容:", content, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("=== EXISTING(既有記憶索引,供去重與合併判斷)===");
|
||||||
|
const rows = [];
|
||||||
|
for (const category of CATEGORIES) {
|
||||||
|
for (const [meta] of listMemories(args.role, category)) {
|
||||||
|
const tags = (meta.tags || []).join("、") || "無";
|
||||||
|
rows.push(`- id: ${meta.id} | 分類: ${CATEGORY_LABELS[category]} | 優先度: ${normalizePriority(meta.priority, category)} | 標籤: ${tags} | 關聯: ${(meta.relevance || []).join("、") || "-"} | links: ${(meta.links || []).join("、") || "-"} | 總結: ${meta.summary || ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.length) {
|
||||||
|
lines.push(...rows.slice(0, existingLimit));
|
||||||
|
if (rows.length > existingLimit) lines.push(`…(既有記憶索引超過 ${existingLimit} 則,已依優先度與更新時間截斷)…`);
|
||||||
|
} else {
|
||||||
|
lines.push("(尚無既有記憶)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = lines.join("\n");
|
||||||
|
if (text.length > collectLimit) {
|
||||||
|
text = `${text.slice(0, collectLimit)}\n…(睡眠整理素材超過 ${collectLimit} 字元預算已截斷,其餘留待下個睡眠週期)…`;
|
||||||
|
}
|
||||||
|
process.stdout.write(text);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJson(text) {
|
||||||
|
let stripped = String(text || "").trim();
|
||||||
|
const fence = stripped.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
|
if (fence) stripped = fence[1].trim();
|
||||||
|
const start = stripped.indexOf("{");
|
||||||
|
const end = stripped.lastIndexOf("}");
|
||||||
|
if (start < 0 || end <= start) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(stripped.slice(start, end + 1));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdApply(args) {
|
||||||
|
const data = extractJson(readStdin());
|
||||||
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||||
|
process.stderr.write("整理結果非合法 JSON\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const entries = data.memories;
|
||||||
|
if (!Array.isArray(entries) || !entries.length) {
|
||||||
|
process.stderr.write("整理結果不含 memories\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ensureLayout(args.role);
|
||||||
|
const stamp = nowStamp();
|
||||||
|
const counts = { new: 0, merge: 0, drop: 0 };
|
||||||
|
const consumed = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
||||||
|
let action = String(entry.action || "new").trim().toLowerCase();
|
||||||
|
const sources = Array.isArray(entry.from) ? entry.from.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||||
|
|
||||||
|
if (action === "drop") {
|
||||||
|
consumed.push(...sources);
|
||||||
|
counts.drop += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = String(entry.content || "").trim().slice(0, CONTENT_LIMIT);
|
||||||
|
const summary = oneLine(entry.summary);
|
||||||
|
const tags = normalizeTags(entry.tags);
|
||||||
|
const entryCategory = normalizeCategory(entry.category);
|
||||||
|
const priority = normalizePriority(entry.priority, entryCategory);
|
||||||
|
const relevance = normalizeList(entry.relevance);
|
||||||
|
const links = normalizeList(entry.links);
|
||||||
|
const sleepStage = oneLine(entry.sleep_stage || entry.sleepStage || "nrem-rem", 40);
|
||||||
|
if (!content && !summary) continue;
|
||||||
|
|
||||||
|
if (action === "merge") {
|
||||||
|
const targetId = String(entry.target || "").trim();
|
||||||
|
const [meta, oldContent] = findMemory(args.role, targetId);
|
||||||
|
if (!meta) {
|
||||||
|
action = "new";
|
||||||
|
} else {
|
||||||
|
const category = normalizeCategory(entry.category || meta.category);
|
||||||
|
const newMeta = {
|
||||||
|
id: meta.id,
|
||||||
|
category,
|
||||||
|
summary: summary || meta.summary || "",
|
||||||
|
tags: normalizeTags([...(meta.tags || []), ...tags]),
|
||||||
|
priority: Math.max(normalizePriority(meta.priority, category), priority),
|
||||||
|
relevance: normalizeList([...(meta.relevance || []), ...relevance]),
|
||||||
|
links: normalizeList([...(meta.links || []), ...links]),
|
||||||
|
sleep_stage: sleepStage,
|
||||||
|
created: meta.created || stamp,
|
||||||
|
updated: stamp,
|
||||||
|
last_replayed: meta.last_replayed || "",
|
||||||
|
hits: Number.parseInt(meta.hits || 0, 10) + 1,
|
||||||
|
};
|
||||||
|
const oldPath = meta.path;
|
||||||
|
const newPath = path.join(root, category, `${meta.id}.md`);
|
||||||
|
fs.writeFileSync(newPath, dumpMemory(newMeta, content || oldContent), "utf8");
|
||||||
|
if (path.resolve(oldPath) !== path.resolve(newPath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(oldPath);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
consumed.push(...sources);
|
||||||
|
counts.merge += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = entryCategory;
|
||||||
|
const meta = {
|
||||||
|
id: newId(content + summary + stamp),
|
||||||
|
category,
|
||||||
|
summary: summary || oneLine(content),
|
||||||
|
tags,
|
||||||
|
priority,
|
||||||
|
relevance,
|
||||||
|
links,
|
||||||
|
sleep_stage: sleepStage,
|
||||||
|
created: stamp,
|
||||||
|
updated: stamp,
|
||||||
|
hits: 1,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(root, category, `${meta.id}.md`), dumpMemory(meta, content || summary), "utf8");
|
||||||
|
consumed.push(...sources);
|
||||||
|
counts.new += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let archived = 0;
|
||||||
|
const d = taipeiDate();
|
||||||
|
const monthDir = path.join(root, "archive", "raw", `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}`);
|
||||||
|
for (const sourceId of new Set(consumed)) {
|
||||||
|
const filePath = path.join(root, "inbox", `${sourceId}.md`);
|
||||||
|
if (fs.existsSync(filePath) && archiveFile(filePath, monthDir)) archived += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = { last_sleep: stamp, last_sleep_epoch: nowEpoch() };
|
||||||
|
if (typeof data.sleepDigest === "string" && data.sleepDigest.trim()) {
|
||||||
|
patch.last_sleep_digest = oneLine(data.sleepDigest, 300);
|
||||||
|
}
|
||||||
|
writeState(args.role, patch);
|
||||||
|
process.stdout.write(`新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived} 則`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdForget(args) {
|
||||||
|
const root = ensureLayout(args.role);
|
||||||
|
const now = new Date();
|
||||||
|
const forgotten = [];
|
||||||
|
for (const [category, [days, maxHits]] of Object.entries(FORGET_RULES)) {
|
||||||
|
for (const [meta] of listMemories(args.role, category)) {
|
||||||
|
const updated = parseStamp(meta.updated) || parseStamp(meta.created);
|
||||||
|
if (!updated) continue;
|
||||||
|
if ((now - updated) / 86400000 < days) continue;
|
||||||
|
if (Number.parseInt(meta.hits || 0, 10) > maxHits) continue;
|
||||||
|
if (normalizePriority(meta.priority, category) > 2) continue;
|
||||||
|
if ((meta.links || []).length) continue;
|
||||||
|
if (args.dryRun) {
|
||||||
|
forgotten.push(`${CATEGORY_LABELS[category]}|${meta.summary || ""}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (archiveFile(meta.path, path.join(root, "archive", "forgotten"))) {
|
||||||
|
forgotten.push(`${CATEGORY_LABELS[category]}|${meta.summary || ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forgotten.length) {
|
||||||
|
process.stdout.write("沒有符合遺忘條件的記憶");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
process.stdout.write(`${args.dryRun ? "(預覽)" : ""}遺忘 ${forgotten.length} 則:\n${forgotten.map((item) => `- ${item}`).join("\n")}`);
|
||||||
|
if (!args.dryRun) writeState(args.role, { last_forget: nowStamp() });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdStats(args) {
|
||||||
|
ensureLayout(args.role);
|
||||||
|
const state = readState(args.role);
|
||||||
|
const rows = ["| 分類 | 筆數 | 平均優先度 |", "| --- | --- | --- |"];
|
||||||
|
for (const category of CATEGORIES) {
|
||||||
|
const items = listMemories(args.role, category);
|
||||||
|
let avg = "-";
|
||||||
|
if (items.length) {
|
||||||
|
const value = items.reduce((sum, [meta]) => sum + normalizePriority(meta.priority, category), 0) / items.length;
|
||||||
|
avg = value.toFixed(1);
|
||||||
|
}
|
||||||
|
rows.push(`| ${CATEGORY_LABELS[category]} | ${items.length} | ${avg} |`);
|
||||||
|
}
|
||||||
|
rows.push(`| 待整理(inbox) | ${listInbox(args.role).length} | - |`);
|
||||||
|
rows.push("");
|
||||||
|
rows.push(`- 記憶目錄:${memoryRoot(args.role)}`);
|
||||||
|
rows.push(`- 上次睡眠整理:${state.last_sleep || "尚未整理"}`);
|
||||||
|
rows.push(`- 上次睡眠摘要:${state.last_sleep_digest || "尚無"}`);
|
||||||
|
rows.push(`- 上次遺忘:${state.last_forget || "尚未執行"}`);
|
||||||
|
process.stdout.write(rows.join("\n"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdMarkSleep(args) {
|
||||||
|
writeState(args.role, { last_sleep: nowStamp(), last_sleep_epoch: nowEpoch() });
|
||||||
|
process.stdout.write("已更新上次整理時間");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdNeedSleep(args) {
|
||||||
|
if (!listInbox(args.role).length) {
|
||||||
|
process.stdout.write("no");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const last = parseStamp(readState(args.role).last_sleep);
|
||||||
|
if (!last) {
|
||||||
|
process.stdout.write("yes");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
process.stdout.write((Date.now() - last.getTime()) / 3600000 >= args.hours ? "yes" : "no");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const command = argv[0];
|
||||||
|
const opts = { command };
|
||||||
|
for (let i = 1; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg.startsWith("--")) continue;
|
||||||
|
const key = arg.slice(2);
|
||||||
|
if (key === "dry-run") {
|
||||||
|
opts.dryRun = true;
|
||||||
|
} else {
|
||||||
|
opts[key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = argv[i + 1] ?? "";
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envInt(name, fallback) {
|
||||||
|
const value = Number.parseInt(process.env[name] || "", 10);
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRole(args) {
|
||||||
|
if (!args.role) {
|
||||||
|
process.stderr.write("缺少 --role\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(argv) {
|
||||||
|
const args = parseArgs(argv);
|
||||||
|
if (!args.command || args.command === "-h" || args.command === "--help") {
|
||||||
|
process.stdout.write(`用法:memory.js <子命令> [參數]\n\n子命令:write、seed、load、collect、apply、forget、stats、mark-sleep、need-sleep\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!requireRole(args)) return 2;
|
||||||
|
|
||||||
|
args.limit = Number.parseInt(args.limit || "", 10);
|
||||||
|
args.batch = Number.parseInt(args.batch || "", 10);
|
||||||
|
args.existingLimit = Number.parseInt(args.existingLimit || "", 10);
|
||||||
|
args.fullMinPriority = Number.parseInt(args.fullMinPriority || "", 10);
|
||||||
|
args.digestMinPriority = Number.parseInt(args.digestMinPriority || "", 10);
|
||||||
|
args.hours = Number.parseFloat(args.hours || "");
|
||||||
|
|
||||||
|
if (!Number.isFinite(args.limit)) args.limit = args.command === "load" ? envInt("ROLE_LOAD_LIMIT", DEFAULT_LOAD_LIMIT) : envInt("ROLE_SLEEP_COLLECT_LIMIT", COLLECT_LIMIT);
|
||||||
|
if (!Number.isFinite(args.batch)) args.batch = envInt("ROLE_SLEEP_BATCH", SLEEP_BATCH);
|
||||||
|
if (!Number.isFinite(args.existingLimit)) args.existingLimit = envInt("ROLE_SLEEP_EXISTING_LIMIT", EXISTING_INDEX_LIMIT);
|
||||||
|
if (!Number.isFinite(args.fullMinPriority)) args.fullMinPriority = envInt("ROLE_LOAD_FULL_MIN_PRIORITY", DEFAULT_FULL_MIN_PRIORITY);
|
||||||
|
if (!Number.isFinite(args.digestMinPriority)) args.digestMinPriority = envInt("ROLE_LOAD_DIGEST_MIN_PRIORITY", DEFAULT_DIGEST_MIN_PRIORITY);
|
||||||
|
if (!Number.isFinite(args.hours)) args.hours = 20.0;
|
||||||
|
args.category ||= "important";
|
||||||
|
args.tags ||= "";
|
||||||
|
args.source ||= "";
|
||||||
|
args.priority ||= "4";
|
||||||
|
args.relevance ||= "explicit,background";
|
||||||
|
args.summary ||= "";
|
||||||
|
args.project ||= "";
|
||||||
|
|
||||||
|
const commands = {
|
||||||
|
write: cmdWrite,
|
||||||
|
seed: cmdSeed,
|
||||||
|
load: cmdLoad,
|
||||||
|
collect: cmdCollect,
|
||||||
|
apply: cmdApply,
|
||||||
|
forget: cmdForget,
|
||||||
|
stats: cmdStats,
|
||||||
|
"mark-sleep": cmdMarkSleep,
|
||||||
|
"need-sleep": cmdNeedSleep,
|
||||||
|
};
|
||||||
|
if (!commands[args.command]) {
|
||||||
|
process.stderr.write("未知子命令\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return commands[args.command](args);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(main(process.argv.slice(2)));
|
||||||
@@ -1,733 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
|
|
||||||
# inbox,(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理
|
|
||||||
# 素材並套用整理結果(分類/去重/標籤/總結/壓縮歸檔),(4) 依使用頻率
|
|
||||||
# 遺忘日常與其他類記憶。
|
|
||||||
# 更新時間:2026/07/28 11:50:00
|
|
||||||
# 相依:Python 3 標準庫。
|
|
||||||
# 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import gzip
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 常數:分類、載入策略、遺忘規則
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 六個分類的目錄名(英文,跨平台安全)與中文標籤
|
|
||||||
CATEGORIES = ["important", "interest", "news", "skill", "daily", "other"]
|
|
||||||
CATEGORY_LABELS = {
|
|
||||||
"important": "重要",
|
|
||||||
"interest": "興趣",
|
|
||||||
"news": "新知",
|
|
||||||
"skill": "技能",
|
|
||||||
"daily": "日常",
|
|
||||||
"other": "其他",
|
|
||||||
}
|
|
||||||
# 中文分類名反查(模型可能直接輸出中文)
|
|
||||||
LABEL_TO_CATEGORY = {label: key for key, label in CATEGORY_LABELS.items()}
|
|
||||||
|
|
||||||
# 載入策略:重要與興趣載入全文,其餘僅載入總結與標籤
|
|
||||||
FULL_CATEGORIES = ["important", "interest"]
|
|
||||||
# 摘要載入順序:技能 → 新知 → 日常 → 其他
|
|
||||||
DIGEST_CATEGORIES = ["skill", "news", "daily", "other"]
|
|
||||||
|
|
||||||
# 遺忘規則:(未更新天數門檻, 命中次數上限);只套用於日常與其他
|
|
||||||
FORGET_RULES = {"daily": (14, 1), "other": (7, 1)}
|
|
||||||
|
|
||||||
# 單次睡眠整理最多處理的 inbox 筆數,其餘留待下個睡眠週期
|
|
||||||
SLEEP_BATCH = 60
|
|
||||||
# 送進模型的素材字元上限
|
|
||||||
COLLECT_LIMIT = 40000
|
|
||||||
# 單則記憶壓縮後的內容字元上限
|
|
||||||
CONTENT_LIMIT = 1200
|
|
||||||
|
|
||||||
TAIPEI = timezone(timedelta(hours=8))
|
|
||||||
STAMP_FORMAT = "%Y/%m/%d %H:%M:%S"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 路徑與時間
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def now_stamp():
|
|
||||||
"""回傳台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串。"""
|
|
||||||
return datetime.now(TAIPEI).strftime(STAMP_FORMAT)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_stamp(value):
|
|
||||||
"""把 yyyy/MM/dd HH:mm:ss 字串解析成帶時區的 datetime,失敗回 None。"""
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.strptime(value.strip(), STAMP_FORMAT).replace(tzinfo=TAIPEI)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def memory_root(role):
|
|
||||||
"""回傳指定角色的記憶根目錄(可用 ROLE_MEMORY_HOME 覆寫預設 ~/.memory)。"""
|
|
||||||
base = os.environ.get("ROLE_MEMORY_HOME") or os.path.join(os.path.expanduser("~"), ".memory")
|
|
||||||
return os.path.join(base, role)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_layout(role):
|
|
||||||
"""建立角色記憶目錄結構(inbox、六個分類、archive),回傳根目錄。"""
|
|
||||||
root = memory_root(role)
|
|
||||||
for sub in ["inbox", "archive/raw", "archive/forgotten"] + CATEGORIES:
|
|
||||||
os.makedirs(os.path.join(root, sub), exist_ok=True)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def state_path(role):
|
|
||||||
"""回傳角色記憶狀態檔(state.json)的路徑。"""
|
|
||||||
return os.path.join(memory_root(role), "state.json")
|
|
||||||
|
|
||||||
|
|
||||||
def read_state(role):
|
|
||||||
"""讀取狀態檔;不存在或損壞時回空 dict。"""
|
|
||||||
try:
|
|
||||||
with open(state_path(role), encoding="utf-8") as fh:
|
|
||||||
data = json.load(fh)
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def write_state(role, patch):
|
|
||||||
"""把 patch 併入狀態檔後寫回(整份覆寫,內容極小)。"""
|
|
||||||
state = read_state(role)
|
|
||||||
state.update(patch)
|
|
||||||
ensure_layout(role)
|
|
||||||
with open(state_path(role), "w", encoding="utf-8") as fh:
|
|
||||||
json.dump(state, fh, ensure_ascii=False, indent=2)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 記憶檔格式:YAML 風格 frontmatter + 內文
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_category(value):
|
|
||||||
"""把模型輸出的分類(英文或中文)正規化為分類鍵;無法判定時回 other。"""
|
|
||||||
raw = (value or "").strip().lower()
|
|
||||||
if raw in CATEGORIES:
|
|
||||||
return raw
|
|
||||||
return LABEL_TO_CATEGORY.get((value or "").strip(), "other")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_tags(value):
|
|
||||||
"""把標籤(list 或逗號分隔字串)正規化為去重後的小寫標籤 list,最多 6 個。"""
|
|
||||||
if isinstance(value, str):
|
|
||||||
parts = re.split(r"[,、|]", value)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
parts = [str(item) for item in value]
|
|
||||||
else:
|
|
||||||
parts = []
|
|
||||||
tags = []
|
|
||||||
for part in parts:
|
|
||||||
tag = part.strip().strip("[]#").strip()
|
|
||||||
if tag and tag.lower() not in [t.lower() for t in tags]:
|
|
||||||
tags.append(tag)
|
|
||||||
return tags[:6]
|
|
||||||
|
|
||||||
|
|
||||||
def one_line(value, limit=120):
|
|
||||||
"""把文字壓成單行並截斷,用於 summary 欄位。"""
|
|
||||||
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
|
||||||
return text[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def dump_memory(meta, content):
|
|
||||||
"""把 meta 與內文組成記憶檔全文(frontmatter + 內文)。"""
|
|
||||||
lines = ["---"]
|
|
||||||
for key in ["id", "category", "summary", "tags", "created", "updated", "hits", "sources"]:
|
|
||||||
if key not in meta:
|
|
||||||
continue
|
|
||||||
value = meta[key]
|
|
||||||
if isinstance(value, list):
|
|
||||||
value = "[" + ", ".join(str(item) for item in value) + "]"
|
|
||||||
lines.append(f"{key}: {value}")
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(content.strip())
|
|
||||||
lines.append("")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def load_memory(path):
|
|
||||||
"""讀取單一記憶檔,回傳 (meta dict, 內文);讀取失敗回 (None, "")。"""
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
|
||||||
raw = fh.read()
|
|
||||||
except OSError:
|
|
||||||
return None, ""
|
|
||||||
|
|
||||||
meta = {"path": path, "hits": 0, "tags": []}
|
|
||||||
body = raw
|
|
||||||
if raw.startswith("---"):
|
|
||||||
parts = raw.split("---", 2)
|
|
||||||
if len(parts) >= 3:
|
|
||||||
body = parts[2]
|
|
||||||
for line in parts[1].splitlines():
|
|
||||||
if ":" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition(":")
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip()
|
|
||||||
if key in ("tags", "sources"):
|
|
||||||
meta[key] = normalize_tags(value.strip("[]"))
|
|
||||||
elif key == "hits":
|
|
||||||
meta[key] = int(value) if value.isdigit() else 0
|
|
||||||
else:
|
|
||||||
meta[key] = value
|
|
||||||
meta.setdefault("id", os.path.splitext(os.path.basename(path))[0])
|
|
||||||
meta.setdefault("summary", "")
|
|
||||||
meta.setdefault("created", "")
|
|
||||||
meta.setdefault("updated", meta.get("created", ""))
|
|
||||||
return meta, body.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def new_id(seed):
|
|
||||||
"""以時間與內容雜湊產生記憶 id,確保同一秒多筆也不碰撞。"""
|
|
||||||
digest = hashlib.sha1(seed.encode("utf-8", "replace")).hexdigest()[:6]
|
|
||||||
return f"{datetime.now(TAIPEI).strftime('%Y%m%d-%H%M%S')}-{digest}"
|
|
||||||
|
|
||||||
|
|
||||||
def list_memories(role, category):
|
|
||||||
"""列出某分類下的所有記憶(依 updated 新到舊排序)。"""
|
|
||||||
directory = os.path.join(memory_root(role), category)
|
|
||||||
items = []
|
|
||||||
if not os.path.isdir(directory):
|
|
||||||
return items
|
|
||||||
for name in sorted(os.listdir(directory)):
|
|
||||||
if not name.endswith(".md"):
|
|
||||||
continue
|
|
||||||
meta, content = load_memory(os.path.join(directory, name))
|
|
||||||
if meta is None:
|
|
||||||
continue
|
|
||||||
meta["category"] = category
|
|
||||||
items.append((meta, content))
|
|
||||||
items.sort(key=lambda item: item[0].get("updated") or "", reverse=True)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def list_inbox(role):
|
|
||||||
"""列出 inbox 內尚未整理的記憶(依檔名,即時間先後排序)。"""
|
|
||||||
directory = os.path.join(memory_root(role), "inbox")
|
|
||||||
items = []
|
|
||||||
if not os.path.isdir(directory):
|
|
||||||
return items
|
|
||||||
for name in sorted(os.listdir(directory)):
|
|
||||||
if not name.endswith(".md"):
|
|
||||||
continue
|
|
||||||
meta, content = load_memory(os.path.join(directory, name))
|
|
||||||
if meta is not None:
|
|
||||||
items.append((meta, content))
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def find_memory(role, memory_id):
|
|
||||||
"""依 id 在六個分類中尋找記憶檔,回傳 (meta, 內文);找不到回 (None, "")。"""
|
|
||||||
for category in CATEGORIES:
|
|
||||||
path = os.path.join(memory_root(role), category, f"{memory_id}.md")
|
|
||||||
if os.path.isfile(path):
|
|
||||||
meta, content = load_memory(path)
|
|
||||||
if meta is not None:
|
|
||||||
meta["category"] = category
|
|
||||||
return meta, content
|
|
||||||
return None, ""
|
|
||||||
|
|
||||||
|
|
||||||
def archive_file(path, destination_dir):
|
|
||||||
"""把檔案 gzip 後搬到歸檔目錄,原檔刪除;失敗時保留原檔。"""
|
|
||||||
os.makedirs(destination_dir, exist_ok=True)
|
|
||||||
target = os.path.join(destination_dir, os.path.basename(path) + ".gz")
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as src, gzip.open(target, "wb") as dst:
|
|
||||||
shutil.copyfileobj(src, dst)
|
|
||||||
os.remove(path)
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:write —— 由 Stop hook 寫入一則未整理記憶
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
FIELD_PATTERN = re.compile(r"^\s*(CATEGORY|SUMMARY|TAGS|CONTENT)\s*[::]\s*(.*)$", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_capture(text):
|
|
||||||
"""
|
|
||||||
解析 Stop hook 濃縮器輸出的四欄格式(CATEGORY/SUMMARY/TAGS/CONTENT)。
|
|
||||||
|
|
||||||
模型可能夾帶前後贅字,故逐行掃描欄位標記,CONTENT 之後的所有內容視為內文。
|
|
||||||
"""
|
|
||||||
category = summary = ""
|
|
||||||
tags = []
|
|
||||||
content_lines = []
|
|
||||||
in_content = False
|
|
||||||
for line in text.splitlines():
|
|
||||||
match = FIELD_PATTERN.match(line)
|
|
||||||
if match and not (in_content and match.group(1).upper() != "CONTENT"):
|
|
||||||
field = match.group(1).upper()
|
|
||||||
value = match.group(2)
|
|
||||||
if field == "CATEGORY":
|
|
||||||
category = value
|
|
||||||
elif field == "SUMMARY":
|
|
||||||
summary = value
|
|
||||||
elif field == "TAGS":
|
|
||||||
tags = normalize_tags(value)
|
|
||||||
elif field == "CONTENT":
|
|
||||||
in_content = True
|
|
||||||
if value.strip():
|
|
||||||
content_lines.append(value)
|
|
||||||
continue
|
|
||||||
if in_content:
|
|
||||||
content_lines.append(line)
|
|
||||||
return category, summary, tags, "\n".join(content_lines).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_write(args):
|
|
||||||
"""把 stdin 的濃縮結果寫成一則 inbox 記憶。"""
|
|
||||||
raw = sys.stdin.read()
|
|
||||||
category, summary, tags, content = parse_capture(raw)
|
|
||||||
if not content and not summary:
|
|
||||||
return 1
|
|
||||||
if not content:
|
|
||||||
content = summary
|
|
||||||
content = content[:CONTENT_LIMIT]
|
|
||||||
stamp = now_stamp()
|
|
||||||
meta = {
|
|
||||||
"id": new_id(content + stamp),
|
|
||||||
"category": normalize_category(category),
|
|
||||||
"summary": one_line(summary) or one_line(content),
|
|
||||||
"tags": tags,
|
|
||||||
"created": stamp,
|
|
||||||
"updated": stamp,
|
|
||||||
"hits": 1,
|
|
||||||
}
|
|
||||||
if args.project:
|
|
||||||
meta["sources"] = [args.project]
|
|
||||||
ensure_layout(args.role)
|
|
||||||
path = os.path.join(memory_root(args.role), "inbox", f"{meta['id']}.md")
|
|
||||||
with open(path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(dump_memory(meta, content))
|
|
||||||
sys.stdout.write(meta["id"])
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:seed —— 新建角色時寫入已整理的初始記憶
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_seed(args):
|
|
||||||
"""把 stdin 寫成一則已整理記憶,用於新建角色時灌入背景資料。"""
|
|
||||||
content = sys.stdin.read().strip()
|
|
||||||
if not content and not args.summary:
|
|
||||||
return 1
|
|
||||||
if not content:
|
|
||||||
content = args.summary
|
|
||||||
content = content[:CONTENT_LIMIT]
|
|
||||||
stamp = now_stamp()
|
|
||||||
category = normalize_category(args.category)
|
|
||||||
meta = {
|
|
||||||
"id": new_id(content + args.summary + stamp),
|
|
||||||
"category": category,
|
|
||||||
"summary": one_line(args.summary) or one_line(content),
|
|
||||||
"tags": normalize_tags(args.tags),
|
|
||||||
"created": stamp,
|
|
||||||
"updated": stamp,
|
|
||||||
"hits": 1,
|
|
||||||
}
|
|
||||||
if args.source:
|
|
||||||
meta["sources"] = [args.source]
|
|
||||||
ensure_layout(args.role)
|
|
||||||
path = os.path.join(memory_root(args.role), category, f"{meta['id']}.md")
|
|
||||||
with open(path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(dump_memory(meta, content))
|
|
||||||
sys.stdout.write(meta["id"])
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:load —— 產生 SessionStart 要注入的記憶區塊
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_load(args):
|
|
||||||
"""
|
|
||||||
組出載入用記憶區塊:重要與興趣載入全文,其餘依技能→新知→日常→其他只載總結與標籤。
|
|
||||||
|
|
||||||
超過字元上限時截斷並標明,避免佔滿 context。
|
|
||||||
"""
|
|
||||||
limit = args.limit
|
|
||||||
blocks = []
|
|
||||||
total_full = 0
|
|
||||||
for category in FULL_CATEGORIES:
|
|
||||||
items = list_memories(args.role, category)
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
lines = [f"### {CATEGORY_LABELS[category]}記憶(全文)"]
|
|
||||||
for meta, content in items:
|
|
||||||
tags = "、".join(meta.get("tags") or []) or "無標籤"
|
|
||||||
lines.append(f"- **{meta.get('summary') or '(無總結)'}**(標籤:{tags})")
|
|
||||||
for line in content.splitlines():
|
|
||||||
if line.strip():
|
|
||||||
lines.append(f" {line.strip()}")
|
|
||||||
total_full += 1
|
|
||||||
blocks.append("\n".join(lines))
|
|
||||||
|
|
||||||
digest_lines = []
|
|
||||||
digest_count = 0
|
|
||||||
for category in DIGEST_CATEGORIES:
|
|
||||||
items = list_memories(args.role, category)
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
digest_lines.append(f"### {CATEGORY_LABELS[category]}記憶(總結)")
|
|
||||||
for meta, _ in items:
|
|
||||||
tags = "、".join(meta.get("tags") or []) or "無標籤"
|
|
||||||
digest_lines.append(f"- {meta.get('summary') or '(無總結)'}(標籤:{tags})")
|
|
||||||
digest_count += 1
|
|
||||||
if digest_lines:
|
|
||||||
blocks.append("\n".join(digest_lines))
|
|
||||||
|
|
||||||
pending = len(list_inbox(args.role))
|
|
||||||
if pending:
|
|
||||||
blocks.append(f"> 尚有 {pending} 則未整理記憶,將於下次睡眠時段歸檔。")
|
|
||||||
|
|
||||||
if not blocks:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
text = "\n\n".join(blocks)
|
|
||||||
if len(text) > limit:
|
|
||||||
text = text[:limit] + f"\n\n> (記憶內容超過 {limit} 字元已截斷,完整記憶仍保存在磁碟)"
|
|
||||||
sys.stdout.write(text)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:collect —— 睡眠整理前輸出待整理素材
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_collect(args):
|
|
||||||
"""輸出送進模型的整理素材:inbox 待整理項目 + 既有記憶索引(供去重比對)。"""
|
|
||||||
inbox = list_inbox(args.role)[:SLEEP_BATCH]
|
|
||||||
if not inbox:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
lines = ["=== INBOX(待整理,每則以 id 標識)==="]
|
|
||||||
for meta, content in inbox:
|
|
||||||
lines.append(f"--- id: {meta['id']} | 時間: {meta.get('created', '-')} ---")
|
|
||||||
lines.append(f"初判分類: {CATEGORY_LABELS.get(meta.get('category', 'other'), '其他')}")
|
|
||||||
lines.append(f"初判總結: {meta.get('summary', '')}")
|
|
||||||
lines.append(f"初判標籤: {'、'.join(meta.get('tags') or []) or '無'}")
|
|
||||||
lines.append("內容:")
|
|
||||||
lines.append(content)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("=== EXISTING(既有記憶索引,供去重與合併判斷)===")
|
|
||||||
existing = 0
|
|
||||||
for category in CATEGORIES:
|
|
||||||
for meta, _ in list_memories(args.role, category):
|
|
||||||
tags = "、".join(meta.get("tags") or []) or "無"
|
|
||||||
lines.append(
|
|
||||||
f"- id: {meta['id']} | 分類: {CATEGORY_LABELS[category]} | 標籤: {tags} | 總結: {meta.get('summary', '')}"
|
|
||||||
)
|
|
||||||
existing += 1
|
|
||||||
if not existing:
|
|
||||||
lines.append("(尚無既有記憶)")
|
|
||||||
|
|
||||||
text = "\n".join(lines)
|
|
||||||
if len(text) > COLLECT_LIMIT:
|
|
||||||
text = text[:COLLECT_LIMIT] + "\n…(素材過長已截斷,其餘留待下個睡眠週期)…"
|
|
||||||
sys.stdout.write(text)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:apply —— 套用睡眠整理結果
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def extract_json(text):
|
|
||||||
"""從模型輸出中取出第一個 JSON 物件(容忍 code fence 與前後贅字)。"""
|
|
||||||
stripped = text.strip()
|
|
||||||
fence = re.search(r"```(?:json)?\s*(.*?)```", stripped, re.DOTALL)
|
|
||||||
if fence:
|
|
||||||
stripped = fence.group(1).strip()
|
|
||||||
start = stripped.find("{")
|
|
||||||
end = stripped.rfind("}")
|
|
||||||
if start < 0 or end <= start:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(stripped[start : end + 1])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_apply(args):
|
|
||||||
"""
|
|
||||||
讀取 stdin 的整理結果 JSON,寫入分類記憶並歸檔對應的 inbox 原始檔。
|
|
||||||
|
|
||||||
action 支援 new(新建)/merge(併入既有記憶)/drop(判定無保存價值)。
|
|
||||||
未被提及的 inbox 檔一律保留,留待下個睡眠週期,避免整理失敗造成記憶遺失。
|
|
||||||
"""
|
|
||||||
data = extract_json(sys.stdin.read())
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
sys.stderr.write("整理結果非合法 JSON\n")
|
|
||||||
return 1
|
|
||||||
entries = data.get("memories")
|
|
||||||
if not isinstance(entries, list) or not entries:
|
|
||||||
sys.stderr.write("整理結果不含 memories\n")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
ensure_layout(args.role)
|
|
||||||
root = memory_root(args.role)
|
|
||||||
stamp = now_stamp()
|
|
||||||
counts = {"new": 0, "merge": 0, "drop": 0}
|
|
||||||
consumed = []
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
action = str(entry.get("action") or "new").strip().lower()
|
|
||||||
sources = [str(item).strip() for item in (entry.get("from") or []) if str(item).strip()]
|
|
||||||
|
|
||||||
if action == "drop":
|
|
||||||
consumed.extend(sources)
|
|
||||||
counts["drop"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
content = str(entry.get("content") or "").strip()[:CONTENT_LIMIT]
|
|
||||||
summary = one_line(entry.get("summary"))
|
|
||||||
tags = normalize_tags(entry.get("tags"))
|
|
||||||
if not content and not summary:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if action == "merge":
|
|
||||||
target_id = str(entry.get("target") or "").strip()
|
|
||||||
meta, old_content = find_memory(args.role, target_id)
|
|
||||||
if meta is None:
|
|
||||||
action = "new"
|
|
||||||
else:
|
|
||||||
category = normalize_category(entry.get("category") or meta.get("category"))
|
|
||||||
merged_tags = normalize_tags((meta.get("tags") or []) + tags)
|
|
||||||
new_meta = {
|
|
||||||
"id": meta["id"],
|
|
||||||
"category": category,
|
|
||||||
"summary": summary or meta.get("summary", ""),
|
|
||||||
"tags": merged_tags,
|
|
||||||
"created": meta.get("created") or stamp,
|
|
||||||
"updated": stamp,
|
|
||||||
"hits": int(meta.get("hits") or 0) + 1,
|
|
||||||
}
|
|
||||||
old_path = meta["path"]
|
|
||||||
new_path = os.path.join(root, category, f"{meta['id']}.md")
|
|
||||||
with open(new_path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(dump_memory(new_meta, content or old_content))
|
|
||||||
if os.path.abspath(old_path) != os.path.abspath(new_path):
|
|
||||||
try:
|
|
||||||
os.remove(old_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
consumed.extend(sources)
|
|
||||||
counts["merge"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
category = normalize_category(entry.get("category"))
|
|
||||||
meta = {
|
|
||||||
"id": new_id(content + summary + stamp),
|
|
||||||
"category": category,
|
|
||||||
"summary": summary or one_line(content),
|
|
||||||
"tags": tags,
|
|
||||||
"created": stamp,
|
|
||||||
"updated": stamp,
|
|
||||||
"hits": 1,
|
|
||||||
}
|
|
||||||
with open(os.path.join(root, category, f"{meta['id']}.md"), "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(dump_memory(meta, content or summary))
|
|
||||||
consumed.extend(sources)
|
|
||||||
counts["new"] += 1
|
|
||||||
|
|
||||||
archived = 0
|
|
||||||
month_dir = os.path.join(root, "archive", "raw", datetime.now(TAIPEI).strftime("%Y-%m"))
|
|
||||||
for source_id in set(consumed):
|
|
||||||
path = os.path.join(root, "inbox", f"{source_id}.md")
|
|
||||||
if os.path.isfile(path) and archive_file(path, month_dir):
|
|
||||||
archived += 1
|
|
||||||
|
|
||||||
write_state(args.role, {"last_sleep": stamp, "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())})
|
|
||||||
sys.stdout.write(
|
|
||||||
f"新增 {counts['new']} 則、合併 {counts['merge']} 則、捨棄 {counts['drop']} 則、歸檔原始記憶 {archived} 則"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:forget —— 依使用頻率遺忘日常與其他
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_forget(args):
|
|
||||||
"""把日常/其他分類中久未更新且命中次數低的記憶壓縮到 archive/forgotten 後移除。"""
|
|
||||||
root = ensure_layout(args.role)
|
|
||||||
now = datetime.now(TAIPEI)
|
|
||||||
forgotten = []
|
|
||||||
for category, (days, max_hits) in FORGET_RULES.items():
|
|
||||||
for meta, _ in list_memories(args.role, category):
|
|
||||||
updated = parse_stamp(meta.get("updated")) or parse_stamp(meta.get("created"))
|
|
||||||
if updated is None:
|
|
||||||
continue
|
|
||||||
if (now - updated).days < days:
|
|
||||||
continue
|
|
||||||
if int(meta.get("hits") or 0) > max_hits:
|
|
||||||
continue
|
|
||||||
if args.dry_run:
|
|
||||||
forgotten.append(f"{CATEGORY_LABELS[category]}|{meta.get('summary', '')}")
|
|
||||||
continue
|
|
||||||
if archive_file(meta["path"], os.path.join(root, "archive", "forgotten")):
|
|
||||||
forgotten.append(f"{CATEGORY_LABELS[category]}|{meta.get('summary', '')}")
|
|
||||||
|
|
||||||
if not forgotten:
|
|
||||||
sys.stdout.write("沒有符合遺忘條件的記憶")
|
|
||||||
return 0
|
|
||||||
prefix = "(預覽)" if args.dry_run else ""
|
|
||||||
sys.stdout.write(f"{prefix}遺忘 {len(forgotten)} 則:\n" + "\n".join(f"- {item}" for item in forgotten))
|
|
||||||
if not args.dry_run:
|
|
||||||
write_state(args.role, {"last_forget": now_stamp()})
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:stats —— 供 skill 與診斷顯示記憶概況
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_stats(args):
|
|
||||||
"""輸出記憶統計(各分類筆數、待整理筆數、上次整理時間)。"""
|
|
||||||
ensure_layout(args.role)
|
|
||||||
state = read_state(args.role)
|
|
||||||
rows = [f"| 分類 | 筆數 |", "| --- | --- |"]
|
|
||||||
for category in CATEGORIES:
|
|
||||||
rows.append(f"| {CATEGORY_LABELS[category]} | {len(list_memories(args.role, category))} |")
|
|
||||||
rows.append(f"| 待整理(inbox) | {len(list_inbox(args.role))} |")
|
|
||||||
rows.append("")
|
|
||||||
rows.append(f"- 記憶目錄:{memory_root(args.role)}")
|
|
||||||
rows.append(f"- 上次睡眠整理:{state.get('last_sleep', '尚未整理')}")
|
|
||||||
rows.append(f"- 上次遺忘:{state.get('last_forget', '尚未執行')}")
|
|
||||||
sys.stdout.write("\n".join(rows))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:need-sleep —— 判斷是否需要補跑睡眠整理
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_mark_sleep(args):
|
|
||||||
"""把本次睡眠週期標記為已整理(inbox 為空、無素材可整理時使用)。"""
|
|
||||||
write_state(args.role, {"last_sleep": now_stamp(), "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())})
|
|
||||||
sys.stdout.write("已更新上次整理時間")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_need_sleep(args):
|
|
||||||
"""
|
|
||||||
判斷是否需要補跑整理:距上次整理超過門檻小時數且 inbox 有內容。
|
|
||||||
|
|
||||||
輸出 yes/no,供 shell 直接判斷(不用解析 JSON)。
|
|
||||||
"""
|
|
||||||
if not list_inbox(args.role):
|
|
||||||
sys.stdout.write("no")
|
|
||||||
return 0
|
|
||||||
state = read_state(args.role)
|
|
||||||
last = parse_stamp(state.get("last_sleep"))
|
|
||||||
if last is None:
|
|
||||||
sys.stdout.write("yes")
|
|
||||||
return 0
|
|
||||||
hours = (datetime.now(TAIPEI) - last).total_seconds() / 3600
|
|
||||||
sys.stdout.write("yes" if hours >= args.hours else "no")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# CLI
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser():
|
|
||||||
"""建立子命令解析器。"""
|
|
||||||
parser = argparse.ArgumentParser(description="角色記憶儲存引擎")
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
write = sub.add_parser("write", help="自 stdin 讀濃縮結果寫入 inbox")
|
|
||||||
write.add_argument("--role", required=True)
|
|
||||||
write.add_argument("--project", default="")
|
|
||||||
write.set_defaults(func=cmd_write)
|
|
||||||
|
|
||||||
seed = sub.add_parser("seed", help="自 stdin 寫入已整理的初始記憶")
|
|
||||||
seed.add_argument("--role", required=True)
|
|
||||||
seed.add_argument("--category", default="important")
|
|
||||||
seed.add_argument("--summary", required=True)
|
|
||||||
seed.add_argument("--tags", default="")
|
|
||||||
seed.add_argument("--source", default="")
|
|
||||||
seed.set_defaults(func=cmd_seed)
|
|
||||||
|
|
||||||
load = sub.add_parser("load", help="輸出 SessionStart 要注入的記憶區塊")
|
|
||||||
load.add_argument("--role", required=True)
|
|
||||||
load.add_argument("--limit", type=int, default=int(os.environ.get("ROLE_LOAD_LIMIT", "8000")))
|
|
||||||
load.set_defaults(func=cmd_load)
|
|
||||||
|
|
||||||
collect = sub.add_parser("collect", help="輸出睡眠整理素材")
|
|
||||||
collect.add_argument("--role", required=True)
|
|
||||||
collect.set_defaults(func=cmd_collect)
|
|
||||||
|
|
||||||
apply_cmd = sub.add_parser("apply", help="自 stdin 讀整理結果 JSON 並套用")
|
|
||||||
apply_cmd.add_argument("--role", required=True)
|
|
||||||
apply_cmd.set_defaults(func=cmd_apply)
|
|
||||||
|
|
||||||
forget = sub.add_parser("forget", help="依使用頻率遺忘日常與其他記憶")
|
|
||||||
forget.add_argument("--role", required=True)
|
|
||||||
forget.add_argument("--dry-run", action="store_true")
|
|
||||||
forget.set_defaults(func=cmd_forget)
|
|
||||||
|
|
||||||
stats = sub.add_parser("stats", help="輸出記憶統計")
|
|
||||||
stats.add_argument("--role", required=True)
|
|
||||||
stats.set_defaults(func=cmd_stats)
|
|
||||||
|
|
||||||
mark = sub.add_parser("mark-sleep", help="標記本次睡眠週期已整理")
|
|
||||||
mark.add_argument("--role", required=True)
|
|
||||||
mark.set_defaults(func=cmd_mark_sleep)
|
|
||||||
|
|
||||||
need = sub.add_parser("need-sleep", help="判斷是否需要補跑睡眠整理")
|
|
||||||
need.add_argument("--role", required=True)
|
|
||||||
need.add_argument("--hours", type=float, default=20.0)
|
|
||||||
need.set_defaults(func=cmd_need_sleep)
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
"""CLI 進入點。"""
|
|
||||||
args = build_parser().parse_args(argv)
|
|
||||||
return args.func(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 用途:Stop hook 主程式。每輪對話結束後抽出本輪內容 → 呼叫 headless CLI 濃縮成
|
# 用途:Stop hook 主程式。每輪對話結束後先用本地規則判斷是否值得記錄;
|
||||||
# 一則記憶(分類/總結/標籤/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/,
|
# 值得記錄時才呼叫 headless CLI 輕量濃縮成一則 inbox 記憶(粗分類/總結/
|
||||||
# 等待睡眠時段整理。睡眠時段雖不載入角色,對話仍照常記錄。
|
# 標籤/優先度/關聯/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/,
|
||||||
# 更新時間:2026/07/28 00:00:00
|
# 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。
|
||||||
# 相依:bash、python3、任一 headless CLI、同目錄的 role_lib.sh/memory.py/transcript.py。
|
# 更新時間:2026/07/28 12:21:11
|
||||||
# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.py redact 遮蔽一次。
|
# 相依:bash、node、任一 headless CLI、同目錄的 role_lib.sh/memory.js/transcript.js。
|
||||||
|
# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。
|
||||||
# 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。
|
# 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
|
|
||||||
role_is_child && exit 0
|
role_is_child && exit 0
|
||||||
role_enabled || exit 0
|
role_enabled || exit 0
|
||||||
command -v python3 >/dev/null 2>&1 || role_quit "找不到 python3,略過記憶記錄" "WRN"
|
command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過記憶記錄" "WRN"
|
||||||
|
|
||||||
ROLE="$(role_resolve_name)"
|
ROLE="$(role_resolve_name)"
|
||||||
[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄"
|
[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄"
|
||||||
@@ -32,18 +33,20 @@ HOOK_INPUT="$(cat)"
|
|||||||
[ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN"
|
[ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN"
|
||||||
|
|
||||||
read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD <<EOF_HOOK
|
read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD <<EOF_HOOK
|
||||||
$(printf '%s' "$HOOK_INPUT" | python3 -c '
|
$(printf '%s' "$HOOK_INPUT" | node -e '
|
||||||
import json, sys
|
let raw = "";
|
||||||
try:
|
process.stdin.setEncoding("utf8");
|
||||||
d = json.load(sys.stdin)
|
process.stdin.on("data", (chunk) => { raw += chunk; });
|
||||||
except ValueError:
|
process.stdin.on("end", () => {
|
||||||
d = {}
|
let d = {};
|
||||||
print(
|
try { d = JSON.parse(raw); } catch {}
|
||||||
d.get("session_id") or d.get("thread_id") or d.get("conversation_id") or "-",
|
process.stdout.write([
|
||||||
d.get("transcript_path") or d.get("session_path") or d.get("conversation_path") or d.get("path") or "-",
|
d.session_id || d.thread_id || d.conversation_id || "-",
|
||||||
"1" if d.get("stop_hook_active") else "0",
|
d.transcript_path || d.session_path || d.conversation_path || d.path || "-",
|
||||||
d.get("cwd", "") or "-",
|
d.stop_hook_active ? "1" : "0",
|
||||||
)
|
d.cwd || "-",
|
||||||
|
].join(" "));
|
||||||
|
});
|
||||||
')
|
')
|
||||||
EOF_HOOK
|
EOF_HOOK
|
||||||
|
|
||||||
@@ -56,24 +59,36 @@ if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
[ -f "$TRANSCRIPT_PATH" ] || role_quit "找不到 transcript:${TRANSCRIPT_PATH}" "WRN"
|
[ -f "$TRANSCRIPT_PATH" ] || role_quit "找不到 transcript:${TRANSCRIPT_PATH}" "WRN"
|
||||||
|
|
||||||
TURN="$(python3 "${SCRIPT_DIR}/transcript.py" extract "$TRANSCRIPT_PATH" 2>/dev/null)"
|
TURN="$(node "${SCRIPT_DIR}/transcript.js" extract "$TRANSCRIPT_PATH" 2>/dev/null)"
|
||||||
[ -n "$TURN" ] || role_quit "本輪無可記錄內容"
|
[ -n "$TURN" ] || role_quit "本輪無可記錄內容"
|
||||||
|
|
||||||
PROJECT="$(role_project_name "$HOOK_CWD")"
|
PROJECT="$(role_project_name "$HOOK_CWD")"
|
||||||
|
CAPTURE_MIN_CHARS="${ROLE_CAPTURE_MIN_CHARS:-240}"
|
||||||
|
CAPTURE_TIMEOUT="${ROLE_CAPTURE_TIMEOUT:-25}"
|
||||||
|
|
||||||
|
if [ "${ROLE_CAPTURE_ENABLED:-1}" = "0" ]; then
|
||||||
|
role_quit "ROLE_CAPTURE_ENABLED=0,略過記憶記錄"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#TURN}" -lt "$CAPTURE_MIN_CHARS" ] && ! printf '%s' "$TURN" | grep -qiE '記住|remember|決定|規範|偏好|preference|always|不要|以後'; then
|
||||||
|
role_quit "本輪低於記憶長度門檻且無明確記憶線索,略過記錄"
|
||||||
|
fi
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# 濃縮:產出一則記憶(四欄固定格式),交由 memory.py 落檔
|
# 濃縮:產出一則輕量 inbox 記憶,交由 memory.js 落檔;完整整理留到睡眠週期
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
PROMPT="$(cat <<EOF_PROMPT
|
PROMPT="$(cat <<EOF_PROMPT
|
||||||
你是角色「${ROLE}」的記憶記錄器。輸入是這位角色與使用者的一段對話(含工具呼叫)。
|
你是角色「${ROLE}」的記憶記錄器。輸入是這位角色與使用者的一段對話(含工具呼叫)。
|
||||||
請把這段對話濃縮成「一則記憶」,規則:
|
請只做「編碼前處理」,把這段對話濃縮成最多一則 inbox 記憶;不要做跨記憶合併或長期整理。
|
||||||
|
|
||||||
已判定專案:${PROJECT}
|
已判定專案:${PROJECT}
|
||||||
|
|
||||||
1. 只輸出下列四個欄位,欄位名稱與順序固定,不要標題、不要前言、不要結語、不要 code fence:
|
1. 只輸出下列欄位,欄位名稱與順序固定,不要標題、不要前言、不要結語、不要 code fence:
|
||||||
CATEGORY: <六選一:important/interest/news/skill/daily/other>
|
CATEGORY: <六選一:important/interest/news/skill/daily/other>
|
||||||
SUMMARY: <一句話總結,40 字內>
|
SUMMARY: <一句話總結,40 字內>
|
||||||
TAGS: <2 至 4 個標籤,以逗號分隔>
|
TAGS: <2 至 4 個標籤,以逗號分隔>
|
||||||
|
PRIORITY: <1 到 5>
|
||||||
|
RELEVANCE: <1 至 4 個,以逗號分隔;explicit/future/repeated/novelty/emotional/temporary/inbox/project>
|
||||||
CONTENT: <3 至 6 行要點,每行以「- 」開頭>
|
CONTENT: <3 至 6 行要點,每行以「- 」開頭>
|
||||||
2. 分類判準:
|
2. 分類判準:
|
||||||
- important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。
|
- important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。
|
||||||
@@ -82,17 +97,18 @@ CONTENT: <3 至 6 行要點,每行以「- 」開頭>
|
|||||||
- skill(技能):可重複套用的做法、指令、流程、除錯手法。
|
- skill(技能):可重複套用的做法、指令、流程、除錯手法。
|
||||||
- daily(日常):一次性的例行工作與雜項處理。
|
- daily(日常):一次性的例行工作與雜項處理。
|
||||||
- other(其他):不屬於上述任何一類。
|
- other(其他):不屬於上述任何一類。
|
||||||
3. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文。
|
3. 優先度判準:5=使用者明確要求記住、長期規範、穩定偏好;4=可重複套用的流程/技能/決策;3=專案相關且未來可能有用;2=短期進度;1=低價值暫存。
|
||||||
4. 使用繁體中文(台灣用語),保留關鍵事實:檔案/專案/指令/數量/分支/議題編號。
|
4. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文。
|
||||||
5. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
5. 使用繁體中文(台灣用語),保留關鍵事實:檔案/專案/指令/數量/分支/議題編號。
|
||||||
6. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論),只輸出一行:SKIP
|
6. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
||||||
|
7. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論、只有簡短狀態回報),只輸出一行:SKIP
|
||||||
|
|
||||||
對話片段:
|
對話片段:
|
||||||
${TURN}
|
${TURN}
|
||||||
EOF_PROMPT
|
EOF_PROMPT
|
||||||
)"
|
)"
|
||||||
|
|
||||||
RESULT="$(role_run_cli "$CLI" "$PROMPT" 45)"
|
RESULT="$(role_run_cli "$CLI" "$PROMPT" "$CAPTURE_TIMEOUT")"
|
||||||
if [ -z "$RESULT" ]; then
|
if [ -z "$RESULT" ]; then
|
||||||
role_log "WRN" "記憶濃縮產出為空(CLI ${CLI}),略過本輪"
|
role_log "WRN" "記憶濃縮產出為空(CLI ${CLI}),略過本輪"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -100,9 +116,9 @@ fi
|
|||||||
printf '%s' "$RESULT" | grep -qiE '^\s*SKIP\s*$' && role_quit "判定本輪無值得記住的內容"
|
printf '%s' "$RESULT" | grep -qiE '^\s*SKIP\s*$' && role_quit "判定本輪無值得記住的內容"
|
||||||
|
|
||||||
# 第二道防線:對模型輸出再遮蔽一次機密與個資
|
# 第二道防線:對模型輸出再遮蔽一次機密與個資
|
||||||
RESULT="$(printf '%s' "$RESULT" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)"
|
RESULT="$(printf '%s' "$RESULT" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)"
|
||||||
|
|
||||||
MEMORY_ID="$(printf '%s' "$RESULT" | python3 "${SCRIPT_DIR}/memory.py" write --role "$ROLE" --project "$PROJECT" 2>/dev/null)"
|
MEMORY_ID="$(printf '%s' "$RESULT" | node "${SCRIPT_DIR}/memory.js" write --role "$ROLE" --project "$PROJECT" 2>/dev/null)"
|
||||||
if [ -n "$MEMORY_ID" ]; then
|
if [ -n "$MEMORY_ID" ]; then
|
||||||
role_log "INF" "已記錄記憶 ${MEMORY_ID}(角色 ${ROLE},專案 ${PROJECT},CLI ${CLI})"
|
role_log "INF" "已記錄記憶 ${MEMORY_ID}(角色 ${ROLE},專案 ${PROJECT},CLI ${CLI})"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
# 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、
|
# 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、
|
||||||
# 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。
|
# 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。
|
||||||
# 本檔僅供 source,不可直接執行。
|
# 本檔僅供 source,不可直接執行。
|
||||||
# 更新時間:2026/07/28 00:00:00
|
# 更新時間:2026/07/28 12:21:11
|
||||||
# 相依:bash、python3;摘要路徑需 README 定義的任一 headless CLI。
|
# 相依:bash;摘要路徑需 README 定義的任一 headless CLI。
|
||||||
# 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。
|
# 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
|
|||||||
+27
-23
@@ -4,8 +4,8 @@
|
|||||||
# 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤;
|
# 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤;
|
||||||
# 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。
|
# 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。
|
||||||
# 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。
|
# 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。
|
||||||
# 更新時間:2026/07/28 11:28:29
|
# 更新時間:2026/07/28 12:21:11
|
||||||
# 相依:bash、python3、同目錄的 role_lib.sh 與 memory.py。
|
# 相依:bash、node、同目錄的 role_lib.sh 與 memory.js。
|
||||||
# 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。
|
# 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
|
|
||||||
role_is_child && exit 0
|
role_is_child && exit 0
|
||||||
role_enabled || exit 0
|
role_enabled || exit 0
|
||||||
command -v python3 >/dev/null 2>&1 || role_quit "找不到 python3,略過角色載入" "WRN"
|
command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過角色載入" "WRN"
|
||||||
|
|
||||||
ROLE="$(role_resolve_name)"
|
ROLE="$(role_resolve_name)"
|
||||||
[ -n "$ROLE" ] || role_quit "未指定角色(ROLE_NAME 與 .active 皆無),略過角色載入"
|
[ -n "$ROLE" ] || role_quit "未指定角色(ROLE_NAME 與 .active 皆無),略過角色載入"
|
||||||
@@ -29,27 +29,31 @@ ROLE_DEF="$(role_file "$ROLE")"
|
|||||||
HOOK_INPUT="$(cat 2>/dev/null)"
|
HOOK_INPUT="$(cat 2>/dev/null)"
|
||||||
HOOK_CWD="$PWD"
|
HOOK_CWD="$PWD"
|
||||||
if [ -n "$HOOK_INPUT" ]; then
|
if [ -n "$HOOK_INPUT" ]; then
|
||||||
HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | python3 -c '
|
HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | node -e '
|
||||||
import json, sys
|
let raw = "";
|
||||||
try:
|
process.stdin.setEncoding("utf8");
|
||||||
data = json.load(sys.stdin)
|
process.stdin.on("data", (chunk) => { raw += chunk; });
|
||||||
except ValueError:
|
process.stdin.on("end", () => {
|
||||||
data = {}
|
let data = {};
|
||||||
print(data.get("cwd") or "")
|
try { data = JSON.parse(raw); } catch {}
|
||||||
|
process.stdout.write(data.cwd || "");
|
||||||
|
});
|
||||||
' 2>/dev/null)"
|
' 2>/dev/null)"
|
||||||
[ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD"
|
[ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD"
|
||||||
fi
|
fi
|
||||||
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}"
|
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}"
|
||||||
|
|
||||||
emit_context() {
|
emit_context() {
|
||||||
# 以 JSON 輸出 additionalContext(由 python 負責跳脫,避免內容含引號或換行破壞格式)
|
# 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式)
|
||||||
printf '%s' "$1" | python3 -c '
|
printf '%s' "$1" | node -e '
|
||||||
import json, sys
|
let context = "";
|
||||||
context = sys.stdin.read()
|
process.stdin.setEncoding("utf8");
|
||||||
print(json.dumps(
|
process.stdin.on("data", (chunk) => { context += chunk; });
|
||||||
{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}},
|
process.stdin.on("end", () => {
|
||||||
ensure_ascii=False,
|
process.stdout.write(JSON.stringify({
|
||||||
))
|
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
|
||||||
|
}));
|
||||||
|
});
|
||||||
'
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +81,11 @@ fi
|
|||||||
DEFINITION="$(cat "$ROLE_DEF" 2>/dev/null)"
|
DEFINITION="$(cat "$ROLE_DEF" 2>/dev/null)"
|
||||||
[ -n "$DEFINITION" ] || role_quit "角色定義檔為空:${ROLE_DEF}" "WRN"
|
[ -n "$DEFINITION" ] || role_quit "角色定義檔為空:${ROLE_DEF}" "WRN"
|
||||||
|
|
||||||
MEMORY="$(python3 "${SCRIPT_DIR}/memory.py" load --role "$ROLE" 2>/dev/null)"
|
MEMORY="$(node "${SCRIPT_DIR}/memory.js" load --role "$ROLE" 2>/dev/null)"
|
||||||
|
|
||||||
# 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理
|
# 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理
|
||||||
CATCHUP_NOTE=""
|
CATCHUP_NOTE=""
|
||||||
if [ "$(python3 "${SCRIPT_DIR}/memory.py" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then
|
if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then
|
||||||
nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 &
|
nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 &
|
||||||
CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n'
|
CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n'
|
||||||
role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE})"
|
role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE})"
|
||||||
@@ -105,13 +109,13 @@ ${DEFINITION}
|
|||||||
|
|
||||||
${MEMORY:-(尚無已整理的記憶。)}
|
${MEMORY:-(尚無已整理的記憶。)}
|
||||||
${CATCHUP_NOTE}
|
${CATCHUP_NOTE}
|
||||||
> 記憶載入規則:重要與興趣記憶載入全文;技能、新知、日常、其他僅載入總結與標籤,
|
> 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 ROLE_LOAD_LIMIT
|
||||||
> 需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。
|
> 字元預算限制;需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。
|
||||||
|
|
||||||
> 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事,
|
> 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事,
|
||||||
> 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔):
|
> 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔):
|
||||||
>
|
>
|
||||||
> \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | python3 "${SCRIPT_DIR}/memory.py" write --role "${ROLE}"\`
|
> \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | node "${SCRIPT_DIR}/memory.js" write --role "${ROLE}"\`
|
||||||
>
|
>
|
||||||
> CATEGORY 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。
|
> CATEGORY 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。
|
||||||
EOF_CONTEXT
|
EOF_CONTEXT
|
||||||
|
|||||||
+31
-23
@@ -2,13 +2,13 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run),
|
# 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run),
|
||||||
# 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶:
|
# 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶:
|
||||||
# 分類(重要/興趣/新知/技能/日常/其他)→ 去重合併 → 設標籤與一句話
|
# NREM 鞏固(分類/去噪/去重/合併/優先度)→ REM 整合(跨記憶
|
||||||
# 總結 → 壓縮內容歸檔 → 日常與其他依使用頻率遺忘。
|
# 連結/抽象化/提取線索)→ 壓縮歸檔 → 日常與其他依使用頻率與優先度遺忘。
|
||||||
# 另提供 --catchup(cron 未執行時的補跑)、--force(手動立即整理)、
|
# 另提供 --catchup(cron 未執行時的補跑)、--force(手動立即整理)、
|
||||||
# --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。
|
# --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。
|
||||||
# 更新時間:2026/07/28 00:00:00
|
# 更新時間:2026/07/28 12:21:11
|
||||||
# 相依:bash、python3、任一 headless CLI、crontab(僅排程安裝需要)、
|
# 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、
|
||||||
# 同目錄的 role_lib.sh 與 memory.py。
|
# 同目錄的 role_lib.sh 與 memory.js。
|
||||||
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
|
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
|
|
||||||
CRON_MARKER="# jsc-role-sleep"
|
CRON_MARKER="# jsc-role-sleep"
|
||||||
SLEEP_TIMEOUT="${ROLE_SLEEP_TIMEOUT:-180}"
|
SLEEP_TIMEOUT="${ROLE_SLEEP_TIMEOUT:-180}"
|
||||||
|
SLEEP_OUTPUT_LIMIT="${ROLE_SLEEP_OUTPUT_LIMIT:-8000}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
# 印出用法
|
# 印出用法
|
||||||
@@ -46,9 +47,9 @@ require_role() {
|
|||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
sleep_cycle() {
|
sleep_cycle() {
|
||||||
# 執行一次完整記憶整理:收集素材 → 模型分類去重 → 落檔歸檔 → 遺忘
|
# 執行一次完整記憶整理:收集素材 → NREM 鞏固 → REM 整合 → 落檔歸檔 → 遺忘
|
||||||
local reason="$1" cli material prompt result applied forgotten
|
local reason="$1" cli material prompt result applied forgotten
|
||||||
command -v python3 >/dev/null 2>&1 || { role_log "ERR" "找不到 python3,無法整理記憶"; return 1; }
|
command -v node >/dev/null 2>&1 || { role_log "ERR" "找不到 node,無法整理記憶"; return 1; }
|
||||||
|
|
||||||
if ! role_lock_acquire "$ROLE"; then
|
if ! role_lock_acquire "$ROLE"; then
|
||||||
role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE})"
|
role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE})"
|
||||||
@@ -56,11 +57,11 @@ sleep_cycle() {
|
|||||||
fi
|
fi
|
||||||
trap 'role_lock_release "$ROLE"' EXIT
|
trap 'role_lock_release "$ROLE"' EXIT
|
||||||
|
|
||||||
material="$(python3 "${SCRIPT_DIR}/memory.py" collect --role "$ROLE" 2>/dev/null)"
|
material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" 2>/dev/null)"
|
||||||
if [ -z "$material" ]; then
|
if [ -z "$material" ]; then
|
||||||
role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason})"
|
role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason})"
|
||||||
python3 "${SCRIPT_DIR}/memory.py" mark-sleep --role "$ROLE" >/dev/null 2>&1
|
node "${SCRIPT_DIR}/memory.js" mark-sleep --role "$ROLE" >/dev/null 2>&1
|
||||||
forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null)"
|
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null)"
|
||||||
role_log "INF" "遺忘檢查:${forgotten}"
|
role_log "INF" "遺忘檢查:${forgotten}"
|
||||||
role_lock_release "$ROLE"
|
role_lock_release "$ROLE"
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
@@ -71,21 +72,28 @@ sleep_cycle() {
|
|||||||
|
|
||||||
prompt="$(cat <<EOF_PROMPT
|
prompt="$(cat <<EOF_PROMPT
|
||||||
你是角色「${ROLE}」的睡眠記憶整理器。輸入包含兩段:INBOX(本次待整理的記憶)與 EXISTING(既有記憶索引)。
|
你是角色「${ROLE}」的睡眠記憶整理器。輸入包含兩段:INBOX(本次待整理的記憶)與 EXISTING(既有記憶索引)。
|
||||||
請把 INBOX 整理成歸檔用的記憶,規則:
|
請模擬睡眠中的兩階段記憶整理,但最後只輸出一個 JSON 物件。
|
||||||
|
|
||||||
1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為:
|
1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為:
|
||||||
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"content":"- 要點\n- 要點","from":["inbox 的 id"]}]}
|
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"sleep_stage":"nrem-rem","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"}
|
||||||
2. action 三選一:
|
2. NREM 鞏固階段先做:去除雜訊與流水帳、遮蔽憑證與個資、分類、去重、合併、壓縮成可長期保存的穩定記憶。
|
||||||
|
3. REM 整合階段再做:找出新記憶與 EXISTING 的關聯,抽出可重複套用的規則、偏好、決策模式、角色語氣調整或未來提取線索。
|
||||||
|
4. action 三選一:
|
||||||
- new:新的一則記憶。多則 INBOX 講同一件事時合成一筆,from 列出全部來源 id。
|
- new:新的一則記憶。多則 INBOX 講同一件事時合成一筆,from 列出全部來源 id。
|
||||||
- merge:內容已被 EXISTING 中某則涵蓋或重複,填 target 為該既有 id,content 寫合併後的完整內容。
|
- merge:內容已被 EXISTING 中某則涵蓋或重複,填 target 為該既有 id,content 寫合併後的完整內容。
|
||||||
- drop:純雜訊、無保存價值,只需填 from。
|
- drop:純雜訊、無保存價值,只需填 from。
|
||||||
3. category 六選一:important(重要)/interest(興趣)/news(新知)/skill(技能)/daily(日常)/other(其他)。
|
5. category 六選一:important(重要)/interest(興趣)/news(新知)/skill(技能)/daily(日常)/other(其他)。
|
||||||
important 放長期偏好、規範、決策與身分背景;interest 放反覆關注的主題;news 放新事實與外部資訊;
|
important 放長期偏好、規範、決策與身分背景;interest 放反覆關注的主題;news 放新事實與外部資訊;
|
||||||
skill 放可重複套用的做法;daily 放一次性例行工作;其餘歸 other。
|
skill 放可重複套用的做法;daily 放一次性例行工作;其餘歸 other。
|
||||||
4. **每一則 INBOX 的 id 都必須出現在某一筆的 from 中**,沒被提及的會留到下個睡眠週期重做。
|
6. priority 必填,1 到 5:5=使用者明確要求、長期規範、穩定偏好或核心身分;4=可重複套用的技能/決策;3=有用新知;2=短期日常;1=低價值但暫存。
|
||||||
5. content 壓縮成 5 行以內要點(每行以「- 」開頭),總長不超過 400 字,去除重複敘述與流水帳。
|
7. relevance 必填 1 至 4 個,從下列語意挑選或用等價繁中詞:explicit(使用者明確要求)、future(未來會用)、repeated(反覆出現)、novelty(新知)、emotional(語氣/情緒/偏好)、temporary(短期)。
|
||||||
6. summary 一句話 40 字內;tags 2 至 4 個。全部使用繁體中文(台灣用語)。
|
8. links 可填 EXISTING 中相關記憶 id;沒有就填空陣列。merge 時若有舊 links,應保留並加上新關聯。
|
||||||
7. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
9. sleep_stage 填 "nrem"、"rem" 或 "nrem-rem"。只有純分類去噪用 nrem;有建立跨記憶連結或抽象規則用 rem 或 nrem-rem。
|
||||||
|
10. **每一則 INBOX 的 id 都必須出現在某一筆的 from 中**,沒被提及的會留到下個睡眠週期重做。
|
||||||
|
11. content 壓縮成 5 行以內要點(每行以「- 」開頭),總長不超過 400 字,去除重複敘述與流水帳。
|
||||||
|
12. summary 一句話 40 字內;tags 2 至 4 個。全部使用繁體中文(台灣用語)。
|
||||||
|
13. sleepDigest 總結本次新增、合併、丟棄、抽象化或建立關聯的重點,80 字內。
|
||||||
|
14. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
||||||
|
|
||||||
素材:
|
素材:
|
||||||
${material}
|
${material}
|
||||||
@@ -100,8 +108,8 @@ EOF_PROMPT
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
result="$(printf '%s' "$result" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)"
|
result="$(printf '%s' "$result" | head -c "$SLEEP_OUTPUT_LIMIT" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)"
|
||||||
applied="$(printf '%s' "$result" | python3 "${SCRIPT_DIR}/memory.py" apply --role "$ROLE" 2>/dev/null)"
|
applied="$(printf '%s' "$result" | node "${SCRIPT_DIR}/memory.js" apply --role "$ROLE" 2>/dev/null)"
|
||||||
if [ -z "$applied" ]; then
|
if [ -z "$applied" ]; then
|
||||||
role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期"
|
role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期"
|
||||||
role_lock_release "$ROLE"
|
role_lock_release "$ROLE"
|
||||||
@@ -110,7 +118,7 @@ EOF_PROMPT
|
|||||||
fi
|
fi
|
||||||
role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}"
|
role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}"
|
||||||
|
|
||||||
forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null | tr '\n' ';')"
|
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null | tr '\n' ';')"
|
||||||
role_log "INF" "遺忘檢查:${forgotten}"
|
role_log "INF" "遺忘檢查:${forgotten}"
|
||||||
|
|
||||||
role_lock_release "$ROLE"
|
role_lock_release "$ROLE"
|
||||||
@@ -201,7 +209,7 @@ show_status() {
|
|||||||
printf '| cron 服務 | %s |\n' "$cron_service"
|
printf '| cron 服務 | %s |\n' "$cron_service"
|
||||||
printf '| 摘要 CLI | %s |\n' "$(role_select_cli 2>/dev/null || printf '找不到可用 CLI')"
|
printf '| 摘要 CLI | %s |\n' "$(role_select_cli 2>/dev/null || printf '找不到可用 CLI')"
|
||||||
printf '\n'
|
printf '\n'
|
||||||
python3 "${SCRIPT_DIR}/memory.py" stats --role "$ROLE" 2>/dev/null
|
node "${SCRIPT_DIR}/memory.js" stats --role "$ROLE" 2>/dev/null
|
||||||
printf '\n'
|
printf '\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +235,7 @@ case "$MODE" in
|
|||||||
--catchup)
|
--catchup)
|
||||||
role_enabled || exit 0
|
role_enabled || exit 0
|
||||||
require_role
|
require_role
|
||||||
if [ "$(python3 "${SCRIPT_DIR}/memory.py" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then
|
if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then
|
||||||
role_log "DBG" "不需補跑整理"
|
role_log "DBG" "不需補跑整理"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+257
@@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ==============================================================================
|
||||||
|
// 用途:角色記憶的 transcript 處理工具。負責 (1) 從 Claude Code/Codex
|
||||||
|
// JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
|
||||||
|
// (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII),
|
||||||
|
// 作為寫入記憶檔前的第二道防線。
|
||||||
|
// 更新時間:2026/07/28 12:21:11
|
||||||
|
// 相依:Node.js 標準庫。抽取與遮蔽全程僅走 stdin/stdout,本檔不寫任何檔案。
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const TOOL_RESULT_LIMIT = 200;
|
||||||
|
const TOOL_INPUT_LIMIT = 160;
|
||||||
|
const TOTAL_LIMIT = 24000;
|
||||||
|
|
||||||
|
const REDACT_PATTERNS = [
|
||||||
|
[/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"],
|
||||||
|
[/\b[0-9a-f]{40}\b/g, "***"],
|
||||||
|
[/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "***"],
|
||||||
|
[/\bsk-[A-Za-z0-9\-_]{16,}\b/g, "***"],
|
||||||
|
[/\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, "***"],
|
||||||
|
[/\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b/g, "***"],
|
||||||
|
[/\b[A-Z][12]\d{8}\b/g, "***"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(0, "utf8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(text) {
|
||||||
|
let output = String(text || "");
|
||||||
|
for (const [pattern, replacement] of REDACT_PATTERNS) {
|
||||||
|
output = output.replace(pattern, replacement);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value) {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRealUserMessage(entry) {
|
||||||
|
const payload = entry.payload;
|
||||||
|
if (isObject(payload) && entry.type === "event_msg") {
|
||||||
|
return payload.type === "user_message" && Boolean(String(payload.message || "").trim());
|
||||||
|
}
|
||||||
|
if (entry.type !== "user") return false;
|
||||||
|
const content = entry.message?.content;
|
||||||
|
if (typeof content === "string") return Boolean(content.trim());
|
||||||
|
if (Array.isArray(content)) return content.some((block) => isObject(block) && block.type === "text");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocks(entry) {
|
||||||
|
const content = entry.message?.content;
|
||||||
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
||||||
|
return Array.isArray(content) ? content : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadTextBlocks(content) {
|
||||||
|
if (typeof content === "string") return [content];
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
const texts = [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (!isObject(block)) continue;
|
||||||
|
if (["input_text", "output_text", "text"].includes(block.type)) {
|
||||||
|
const text = String(block.text || "").trim();
|
||||||
|
if (text) texts.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCodexPayload(entry) {
|
||||||
|
const payload = entry.payload;
|
||||||
|
if (!isObject(payload)) return [];
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
const entryType = entry.type;
|
||||||
|
const payloadType = payload.type;
|
||||||
|
|
||||||
|
if (entryType === "event_msg") {
|
||||||
|
if (payloadType === "user_message") {
|
||||||
|
const message = String(payload.message || "").trim();
|
||||||
|
if (message) lines.push(`[user] ${message}`);
|
||||||
|
} else if (payloadType === "agent_message") {
|
||||||
|
const message = String(payload.message || "").trim();
|
||||||
|
if (message) lines.push(`[assistant:${payload.phase || "assistant"}] ${message}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryType !== "response_item") return lines;
|
||||||
|
if (payloadType === "message") {
|
||||||
|
const role = payload.role || "assistant";
|
||||||
|
if (role === "system" || role === "developer") return lines;
|
||||||
|
for (const text of payloadTextBlocks(payload.content)) {
|
||||||
|
if (role === "user" && text.trimStart().startsWith("<skill>")) continue;
|
||||||
|
if (role === "user" && text.trimStart().startsWith("<environment_context>")) continue;
|
||||||
|
lines.push(`[${role}] ${text}`);
|
||||||
|
}
|
||||||
|
} else if (payloadType === "function_call") {
|
||||||
|
const raw = String(payload.arguments || "").trim().replace(/\n/g, " ");
|
||||||
|
lines.push(`[tool:${payload.name || "?"}] ${raw.slice(0, TOOL_INPUT_LIMIT)}`);
|
||||||
|
} else if (payloadType === "function_call_output") {
|
||||||
|
const raw = String(payload.output || "").trim().replace(/\n/g, " ");
|
||||||
|
if (raw) lines.push(`[result] ${raw.slice(0, TOOL_RESULT_LIMIT)}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(entry) {
|
||||||
|
const codexLines = renderCodexPayload(entry);
|
||||||
|
if (codexLines.length) return codexLines;
|
||||||
|
|
||||||
|
const role = entry.type;
|
||||||
|
const lines = [];
|
||||||
|
for (const block of blocks(entry)) {
|
||||||
|
if (!isObject(block)) continue;
|
||||||
|
if (block.type === "text") {
|
||||||
|
const text = String(block.text || "").trim();
|
||||||
|
if (text) lines.push(`[${role}] ${text}`);
|
||||||
|
} else if (block.type === "tool_use") {
|
||||||
|
const raw = JSON.stringify(block.input || {});
|
||||||
|
lines.push(`[tool:${block.name || "?"}] ${raw.slice(0, TOOL_INPUT_LIMIT)}`);
|
||||||
|
} else if (block.type === "tool_result") {
|
||||||
|
let raw = block.content;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
raw = raw.map((item) => (isObject(item) && item.type === "text" ? item.text || "" : "")).join(" ");
|
||||||
|
}
|
||||||
|
raw = String(raw || "").trim().replace(/\n/g, " ");
|
||||||
|
if (raw) lines.push(`[result] ${raw.slice(0, TOOL_RESULT_LIMIT)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEntries(filePath) {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entries = [];
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
entries.push(JSON.parse(line));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function turnStartIndex(entries) {
|
||||||
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (isRealUserMessage(entries[index])) return index;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimestamp(value) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) return null;
|
||||||
|
const ms = Date.parse(value.trim());
|
||||||
|
return Number.isNaN(ms) ? null : new Date(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryTimestamp(entry) {
|
||||||
|
for (const key of ["timestamp", "created_at", "time"]) {
|
||||||
|
const dt = parseTimestamp(entry[key]);
|
||||||
|
if (dt) return dt;
|
||||||
|
}
|
||||||
|
if (isObject(entry.message)) {
|
||||||
|
for (const key of ["timestamp", "created_at", "time"]) {
|
||||||
|
const dt = parseTimestamp(entry.message[key]);
|
||||||
|
if (dt) return dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds < 0) return "未判定";
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes <= 0) return "1 分鐘內";
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours && mins) return `${hours} 小時 ${mins} 分鐘`;
|
||||||
|
if (hours) return `${hours} 小時`;
|
||||||
|
return `${mins} 分鐘`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function turnDuration(filePath) {
|
||||||
|
const entries = readEntries(filePath);
|
||||||
|
if (!entries.length) return "未判定";
|
||||||
|
const start = turnStartIndex(entries);
|
||||||
|
const stamps = entries.slice(start).map(entryTimestamp).filter(Boolean);
|
||||||
|
if (stamps.length < 2) return "未判定";
|
||||||
|
const min = Math.min(...stamps.map((dt) => dt.getTime()));
|
||||||
|
const max = Math.max(...stamps.map((dt) => dt.getTime()));
|
||||||
|
return formatDuration((max - min) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTurn(filePath) {
|
||||||
|
const entries = readEntries(filePath);
|
||||||
|
if (!entries.length) return "";
|
||||||
|
const start = turnStartIndex(entries);
|
||||||
|
const lines = [];
|
||||||
|
for (const entry of entries.slice(start)) lines.push(...render(entry));
|
||||||
|
let text = lines.join("\n").trim();
|
||||||
|
if (text.length > TOTAL_LIMIT) {
|
||||||
|
const half = Math.floor(TOTAL_LIMIT / 2);
|
||||||
|
text = `${text.slice(0, half)}\n…(中段省略)…\n${text.slice(-half)}`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE = `用法:transcript.js <子命令> [參數]
|
||||||
|
|
||||||
|
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
|
||||||
|
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
|
||||||
|
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
|
||||||
|
`;
|
||||||
|
|
||||||
|
function main(argv) {
|
||||||
|
if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
|
||||||
|
process.stdout.write(USAGE);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (argv[0] === "extract") {
|
||||||
|
if (argv.length < 2) return 2;
|
||||||
|
const text = extractTurn(argv[1]);
|
||||||
|
if (!text) return 1;
|
||||||
|
process.stdout.write(redact(text));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (argv[0] === "duration") {
|
||||||
|
if (argv.length < 2) return 2;
|
||||||
|
process.stdout.write(turnDuration(argv[1]));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (argv[0] === "redact") {
|
||||||
|
process.stdout.write(redact(readStdin()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
process.stdout.write(USAGE);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(main(process.argv.slice(2)));
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:角色記憶的 transcript 處理工具。負責 (1) 從 Claude Code/Codex
|
|
||||||
# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
|
|
||||||
# (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII),
|
|
||||||
# 作為寫入記憶檔前的第二道防線。
|
|
||||||
# 更新時間:2026/07/28 00:00:00
|
|
||||||
# 相依:Python 3 標準庫。抽取與遮蔽全程僅走 stdin/stdout,本檔不寫任何檔案。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入
|
|
||||||
TOOL_RESULT_LIMIT = 200
|
|
||||||
TOOL_INPUT_LIMIT = 160
|
|
||||||
TOTAL_LIMIT = 24000
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 機密遮蔽規則:命中一律換成 ***
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
REDACT_PATTERNS = [
|
|
||||||
(r"[A-Za-z0-9_\-]*:[A-Za-z0-9_\-]{16,}@", "***@"), # URL 內嵌憑證 user:token@
|
|
||||||
(r"\b[0-9a-f]{40}\b", "***"), # Gitea 40 字元 token
|
|
||||||
(r"\bgh[pousr]_[A-Za-z0-9_]{16,}\b", "***"), # GitHub token
|
|
||||||
(r"\bsk-[A-Za-z0-9\-_]{16,}\b", "***"), # API key
|
|
||||||
(r"(?i)\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+", r"\1=***"),
|
|
||||||
(r"(?i)Authorization:\s*(token|bearer)\s+\S+", r"Authorization: \1 ***"),
|
|
||||||
(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", "***"), # Email
|
|
||||||
(r"\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b", "***"), # 台灣手機
|
|
||||||
(r"\b[A-Z][12]\d{8}\b", "***"), # 身分證字號
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def redact(text):
|
|
||||||
"""對文字套用全部機密遮蔽規則,回傳遮蔽後的結果。"""
|
|
||||||
for pattern, replacement in REDACT_PATTERNS:
|
|
||||||
text = re.sub(pattern, replacement, text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _is_real_user_message(entry):
|
|
||||||
"""判斷 transcript 條目是否為真正的使用者輸入(排除工具回填與環境注入)。"""
|
|
||||||
payload = entry.get("payload")
|
|
||||||
if isinstance(payload, dict) and entry.get("type") == "event_msg":
|
|
||||||
return payload.get("type") == "user_message" and bool(str(payload.get("message") or "").strip())
|
|
||||||
|
|
||||||
if entry.get("type") != "user":
|
|
||||||
return False
|
|
||||||
content = entry.get("message", {}).get("content")
|
|
||||||
if isinstance(content, str):
|
|
||||||
return bool(content.strip())
|
|
||||||
if isinstance(content, list):
|
|
||||||
return any(b.get("type") == "text" for b in content if isinstance(b, dict))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _blocks(entry):
|
|
||||||
"""取出條目的 content blocks,統一為 list 形式。"""
|
|
||||||
content = entry.get("message", {}).get("content")
|
|
||||||
if isinstance(content, str):
|
|
||||||
return [{"type": "text", "text": content}]
|
|
||||||
return content if isinstance(content, list) else []
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_text_blocks(content):
|
|
||||||
"""把 Codex response_item 的 content blocks 轉成純文字片段。"""
|
|
||||||
if isinstance(content, str):
|
|
||||||
return [content]
|
|
||||||
if not isinstance(content, list):
|
|
||||||
return []
|
|
||||||
texts = []
|
|
||||||
for block in content:
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
if block.get("type") in ("input_text", "output_text", "text"):
|
|
||||||
text = (block.get("text") or "").strip()
|
|
||||||
if text:
|
|
||||||
texts.append(text)
|
|
||||||
return texts
|
|
||||||
|
|
||||||
|
|
||||||
def _render_codex_payload(entry):
|
|
||||||
"""將 Codex session JSONL 的 payload 格式轉為摘要輸入用純文字。"""
|
|
||||||
payload = entry.get("payload")
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return []
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
entry_type = entry.get("type")
|
|
||||||
payload_type = payload.get("type")
|
|
||||||
|
|
||||||
if entry_type == "event_msg":
|
|
||||||
if payload_type == "user_message":
|
|
||||||
message = (payload.get("message") or "").strip()
|
|
||||||
if message:
|
|
||||||
lines.append(f"[user] {message}")
|
|
||||||
elif payload_type == "agent_message":
|
|
||||||
message = (payload.get("message") or "").strip()
|
|
||||||
if message:
|
|
||||||
phase = payload.get("phase") or "assistant"
|
|
||||||
lines.append(f"[assistant:{phase}] {message}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
if entry_type != "response_item":
|
|
||||||
return lines
|
|
||||||
|
|
||||||
if payload_type == "message":
|
|
||||||
role = payload.get("role") or "assistant"
|
|
||||||
if role in ("system", "developer"):
|
|
||||||
return lines
|
|
||||||
for text in _payload_text_blocks(payload.get("content")):
|
|
||||||
# Codex 會把 skill 內容以 user role 注入;避免把整份 SKILL.md 當成本輪工作。
|
|
||||||
if role == "user" and text.lstrip().startswith("<skill>"):
|
|
||||||
continue
|
|
||||||
if role == "user" and text.lstrip().startswith("<environment_context>"):
|
|
||||||
continue
|
|
||||||
lines.append(f"[{role}] {text}")
|
|
||||||
elif payload_type == "function_call":
|
|
||||||
name = payload.get("name") or "?"
|
|
||||||
raw = str(payload.get("arguments") or "").strip().replace("\n", " ")
|
|
||||||
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
|
|
||||||
elif payload_type == "function_call_output":
|
|
||||||
raw = str(payload.get("output") or "").strip().replace("\n", " ")
|
|
||||||
if raw:
|
|
||||||
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _render(entry):
|
|
||||||
"""將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。"""
|
|
||||||
codex_lines = _render_codex_payload(entry)
|
|
||||||
if codex_lines:
|
|
||||||
return codex_lines
|
|
||||||
|
|
||||||
role = entry.get("type")
|
|
||||||
lines = []
|
|
||||||
for block in _blocks(entry):
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
kind = block.get("type")
|
|
||||||
if kind == "text":
|
|
||||||
text = (block.get("text") or "").strip()
|
|
||||||
if text:
|
|
||||||
lines.append(f"[{role}] {text}")
|
|
||||||
elif kind == "tool_use":
|
|
||||||
name = block.get("name", "?")
|
|
||||||
raw = json.dumps(block.get("input", {}), ensure_ascii=False)
|
|
||||||
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
|
|
||||||
elif kind == "tool_result":
|
|
||||||
raw = block.get("content")
|
|
||||||
if isinstance(raw, list):
|
|
||||||
raw = " ".join(
|
|
||||||
b.get("text", "") for b in raw if isinstance(b, dict) and b.get("type") == "text"
|
|
||||||
)
|
|
||||||
raw = str(raw or "").strip().replace("\n", " ")
|
|
||||||
if raw:
|
|
||||||
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _read_entries(path):
|
|
||||||
"""讀取 transcript JSONL,忽略無法解析的列。"""
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
|
||||||
entries = []
|
|
||||||
for line in fh:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
entries.append(json.loads(line))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def _turn_start_index(entries):
|
|
||||||
"""找出本輪起點:最後一筆真正使用者訊息的位置。"""
|
|
||||||
start = 0
|
|
||||||
for index in range(len(entries) - 1, -1, -1):
|
|
||||||
if _is_real_user_message(entries[index]):
|
|
||||||
start = index
|
|
||||||
break
|
|
||||||
return start
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value):
|
|
||||||
"""解析常見 transcript timestamp 格式,失敗回 None。"""
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
return None
|
|
||||||
raw = value.strip()
|
|
||||||
if raw.endswith("Z"):
|
|
||||||
raw = raw[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(raw)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
def _entry_timestamp(entry):
|
|
||||||
"""取出 transcript 條目的時間欄位。"""
|
|
||||||
for key in ("timestamp", "created_at", "time"):
|
|
||||||
dt = _parse_timestamp(entry.get(key))
|
|
||||||
if dt:
|
|
||||||
return dt
|
|
||||||
message = entry.get("message")
|
|
||||||
if isinstance(message, dict):
|
|
||||||
for key in ("timestamp", "created_at", "time"):
|
|
||||||
dt = _parse_timestamp(message.get(key))
|
|
||||||
if dt:
|
|
||||||
return dt
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds):
|
|
||||||
"""把秒數格式化為精簡中文耗時。"""
|
|
||||||
if seconds < 0:
|
|
||||||
return "未判定"
|
|
||||||
minutes = int(round(seconds / 60))
|
|
||||||
if minutes <= 0:
|
|
||||||
return "1 分鐘內"
|
|
||||||
hours, mins = divmod(minutes, 60)
|
|
||||||
if hours and mins:
|
|
||||||
return f"{hours} 小時 {mins} 分鐘"
|
|
||||||
if hours:
|
|
||||||
return f"{hours} 小時"
|
|
||||||
return f"{mins} 分鐘"
|
|
||||||
|
|
||||||
|
|
||||||
def turn_duration(path):
|
|
||||||
"""
|
|
||||||
估算本輪花費時間:取本輪起點到最後一筆可解析 timestamp 的差距。
|
|
||||||
|
|
||||||
transcript 無時間欄位或本輪少於兩個時間點時回「未判定」,避免臆測。
|
|
||||||
"""
|
|
||||||
entries = _read_entries(path)
|
|
||||||
if not entries:
|
|
||||||
return "未判定"
|
|
||||||
start = _turn_start_index(entries)
|
|
||||||
stamps = [dt for dt in (_entry_timestamp(e) for e in entries[start:]) if dt]
|
|
||||||
if len(stamps) < 2:
|
|
||||||
return "未判定"
|
|
||||||
return format_duration((max(stamps) - min(stamps)).total_seconds())
|
|
||||||
|
|
||||||
|
|
||||||
def extract_turn(path):
|
|
||||||
"""
|
|
||||||
從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。
|
|
||||||
|
|
||||||
不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。
|
|
||||||
回傳純文字字串;讀取失敗或無內容時回空字串。
|
|
||||||
"""
|
|
||||||
entries = _read_entries(path)
|
|
||||||
if not entries:
|
|
||||||
return ""
|
|
||||||
start = _turn_start_index(entries)
|
|
||||||
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
for entry in entries[start:]:
|
|
||||||
lines.extend(_render(entry))
|
|
||||||
|
|
||||||
text = "\n".join(lines).strip()
|
|
||||||
if len(text) > TOTAL_LIMIT:
|
|
||||||
head = text[: TOTAL_LIMIT // 2]
|
|
||||||
tail = text[-TOTAL_LIMIT // 2 :]
|
|
||||||
text = f"{head}\n…(中段省略)…\n{tail}"
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
USAGE = """用法:transcript.py <子命令> [參數]
|
|
||||||
|
|
||||||
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
|
|
||||||
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
|
|
||||||
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
"""CLI 進入點:解析子命令並執行抽取或遮蔽。"""
|
|
||||||
if not argv or argv[0] in ("-h", "--help"):
|
|
||||||
print(USAGE)
|
|
||||||
return 0
|
|
||||||
if argv[0] == "extract":
|
|
||||||
if len(argv) < 2:
|
|
||||||
return 2
|
|
||||||
text = extract_turn(argv[1])
|
|
||||||
if not text:
|
|
||||||
return 1
|
|
||||||
sys.stdout.write(redact(text))
|
|
||||||
return 0
|
|
||||||
if argv[0] == "duration":
|
|
||||||
if len(argv) < 2:
|
|
||||||
return 2
|
|
||||||
sys.stdout.write(turn_duration(argv[1]))
|
|
||||||
return 0
|
|
||||||
if argv[0] == "redact":
|
|
||||||
sys.stdout.write(redact(sys.stdin.read()))
|
|
||||||
return 0
|
|
||||||
print(USAGE)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
Reference in New Issue
Block a user