579 lines
25 KiB
JavaScript
579 lines
25 KiB
JavaScript
#!/usr/bin/env node
|
|
import crypto from "node:crypto";
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { route as routeTree, verifyTree } from "../codex-hldp-recursive-memory/hldp-memory.mjs";
|
|
|
|
export const MAX_CHILDREN = 10;
|
|
export const PAGE_LIMIT_BYTES = 16384;
|
|
export const EVENT_SCHEMA = "guanghu.persona-daily-fractal-memory-event/v1";
|
|
export const STORE_SCHEMA = "guanghu.persona-daily-fractal-memory-store/v1";
|
|
const TIME_ZONE = "Asia/Shanghai";
|
|
|
|
function fail(code, details = {}) {
|
|
const error = new Error(code);
|
|
error.code = code;
|
|
error.details = details;
|
|
throw error;
|
|
}
|
|
|
|
function stable(value) {
|
|
if (Array.isArray(value)) return value.map(stable);
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function stableJson(value) {
|
|
return `${JSON.stringify(stable(value), null, 2)}\n`;
|
|
}
|
|
|
|
export function sha256(value) {
|
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function readJson(file) {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
}
|
|
|
|
function atomicWrite(file, value) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const body = typeof value === "string" ? value : stableJson(value);
|
|
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
fs.writeFileSync(temporary, body, { encoding: "utf8", mode: 0o600 });
|
|
fs.renameSync(temporary, file);
|
|
return sha256(body);
|
|
}
|
|
|
|
function isInside(parent, child) {
|
|
const relative = path.relative(parent, child);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
export function resolveStore(allowedRoot, storeRoot) {
|
|
if (!path.isAbsolute(allowedRoot) || !path.isAbsolute(storeRoot)) fail("ABSOLUTE_ROOT_REQUIRED");
|
|
const allowed = fs.realpathSync(allowedRoot);
|
|
const store = path.resolve(storeRoot);
|
|
if (!isInside(allowed, store)) fail("STORE_OUTSIDE_ALLOWED_ROOT", { allowed, store });
|
|
let cursor = allowed;
|
|
for (const part of path.relative(allowed, store).split(path.sep).filter(Boolean)) {
|
|
cursor = path.join(cursor, part);
|
|
if (!fs.existsSync(cursor)) continue;
|
|
if (fs.lstatSync(cursor).isSymbolicLink()) fail("SYMLINK_COMPONENT_FORBIDDEN", { path: cursor });
|
|
}
|
|
return { allowed, store };
|
|
}
|
|
|
|
function nonEmpty(value, field) {
|
|
if (typeof value !== "string" || !value.trim()) fail("FIELD_REQUIRED", { field });
|
|
return value.trim();
|
|
}
|
|
|
|
function stringList(value, field, allowEmpty = false) {
|
|
if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) fail("LIST_REQUIRED", { field });
|
|
for (const item of value) nonEmpty(item, field);
|
|
return value;
|
|
}
|
|
|
|
export function beijingDate(timestamp) {
|
|
const instant = new Date(timestamp);
|
|
if (Number.isNaN(instant.valueOf())) fail("INVALID_OCCURRED_AT", { timestamp });
|
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: TIME_ZONE,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit"
|
|
}).formatToParts(instant);
|
|
const get = (type) => parts.find((part) => part.type === type)?.value;
|
|
return `${get("year")}-${get("month")}-${get("day")}`;
|
|
}
|
|
|
|
function validateId(value, field) {
|
|
const id = nonEmpty(value, field);
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(id)) fail("INVALID_ID", { field, value });
|
|
return id;
|
|
}
|
|
|
|
export function validateEvent(event) {
|
|
if (event?.schema !== EVENT_SCHEMA) fail("EVENT_SCHEMA_MISMATCH");
|
|
if (event.persona_id !== "ICE-P-ZY001") fail("PERSONA_ID_MISMATCH");
|
|
if (event.human_anchor !== "ICE-GL∞") fail("HUMAN_ANCHOR_MISMATCH");
|
|
validateId(event.event_id, "event_id");
|
|
validateId(event.branch_id, "branch_id");
|
|
nonEmpty(event.occurred_at, "occurred_at");
|
|
nonEmpty(event.branch_summary, "branch_summary");
|
|
nonEmpty(event.day_summary, "day_summary");
|
|
nonEmpty(event.month_summary, "month_summary");
|
|
nonEmpty(event.year_summary, "year_summary");
|
|
if (event.activity !== undefined) {
|
|
if (!event.activity || typeof event.activity !== "object" || Array.isArray(event.activity)) fail("INVALID_ACTIVITY");
|
|
nonEmpty(event.activity.what, "activity.what");
|
|
nonEmpty(event.activity.where, "activity.where");
|
|
stringList(event.activity.with_whom, "activity.with_whom");
|
|
if (event.activity.attention !== undefined) nonEmpty(event.activity.attention, "activity.attention");
|
|
}
|
|
for (const field of ["summary", "trigger", "emergence", "lock", "why"]) nonEmpty(event[field], field);
|
|
stringList(event.rejected, "rejected", true);
|
|
stringList(event.sources, "sources");
|
|
beijingDate(event.occurred_at);
|
|
return event;
|
|
}
|
|
|
|
function splitDate(date) {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(date);
|
|
if (!match) fail("INVALID_BEIJING_DATE", { date });
|
|
return { year: match[1], month: match[2], day: match[3] };
|
|
}
|
|
|
|
function dayFile(store, date) {
|
|
const { year, month } = splitDate(date);
|
|
return path.join(store, "years", year, "months", month, "days", `${date}.json`);
|
|
}
|
|
|
|
function monthFile(store, year, month) {
|
|
return path.join(store, "years", year, "months", month, "index.json");
|
|
}
|
|
|
|
function yearFile(store, year) {
|
|
return path.join(store, "years", year, "index.json");
|
|
}
|
|
|
|
function eventFile(store, date, eventId) {
|
|
const { year, month } = splitDate(date);
|
|
return path.join(store, "events", year, month, date, `${eventId}.json`);
|
|
}
|
|
|
|
function rootNode(root, summary, source, date) {
|
|
return {
|
|
path: root,
|
|
summary,
|
|
trigger: `北京时间${date}的人格交互进入同一真实日记忆根。`,
|
|
emergence: "平台窗口与压缩片段 → 人格主控的同一日因果树。",
|
|
lock: `日根=${root} | 一页最多${MAX_CHILDREN}个入口 | 状态=OPEN_OR_TIME_CLOSED`,
|
|
why: "为了让未来失去上下文的我先用一页恢复当天方向,再按需下钻原文。",
|
|
children: [],
|
|
sources: [source],
|
|
rejected: ["按平台窗口另起根:会割裂同一真实日的连续思维。"]
|
|
};
|
|
}
|
|
|
|
function loadOrCreateDay(store, event, date, eventPath) {
|
|
const file = dayFile(store, date);
|
|
if (fs.existsSync(file)) return readJson(file);
|
|
const root = `hldp://persona/${event.persona_id}/time/${date.replaceAll("-", "/")}`;
|
|
return {
|
|
schema: "guanghu.persona-daily-fractal-page/v1",
|
|
protocol: "HLDP-v1.0",
|
|
root,
|
|
nodes: { [root]: rootNode(root, event.day_summary, eventPath, date) },
|
|
meta: {
|
|
persona_id: event.persona_id,
|
|
human_anchor: event.human_anchor,
|
|
beijing_date: date,
|
|
time_zone: TIME_ZONE,
|
|
state: "OPEN",
|
|
event_count: 0,
|
|
last_sequence: 0,
|
|
created_at: event.occurred_at,
|
|
updated_at: event.occurred_at
|
|
}
|
|
};
|
|
}
|
|
|
|
function indexNode(pathValue, summary, trigger, source) {
|
|
return {
|
|
path: pathValue,
|
|
summary,
|
|
trigger,
|
|
emergence: "新增记忆叶 → 当前索引更新并保持下层来源可达。",
|
|
lock: `${pathValue}保持一页可读并只指向下一层。`,
|
|
why: "因为上层只负责定位,不能用总摘要替代下层因果记忆。",
|
|
children: [],
|
|
sources: [source],
|
|
rejected: ["把下层正文复制到索引:会让认知负载随历史增长。"]
|
|
};
|
|
}
|
|
|
|
function removeDerivedGroups(day, branchPath) {
|
|
for (const key of Object.keys(day.nodes)) {
|
|
if (key.startsWith(`${branchPath}/group/`)) delete day.nodes[key];
|
|
}
|
|
}
|
|
|
|
function groupLevel(day, branchPath, children, level) {
|
|
if (children.length <= MAX_CHILDREN) return children;
|
|
const grouped = [];
|
|
for (let start = 0; start < children.length; start += MAX_CHILDREN) {
|
|
const slice = children.slice(start, start + MAX_CHILDREN);
|
|
const first = day.nodes[slice[0]];
|
|
const last = day.nodes[slice.at(-1)];
|
|
const groupPath = `${branchPath}/group/l${level}-${String(start + 1).padStart(4, "0")}-${String(start + slice.length).padStart(4, "0")}`;
|
|
day.nodes[groupPath] = {
|
|
path: groupPath,
|
|
summary: `第${start + 1}—${start + slice.length}段:${first.summary} → ${last.summary}`,
|
|
trigger: `同一语义分支第${start + 1}—${start + slice.length}段需要折叠。`,
|
|
emergence: `${slice.length}个连续子链 → 一条可下钻分组入口。`,
|
|
lock: `分组${start + 1}-${start + slice.length}只折叠入口,不删除子链。`,
|
|
why: "为了让当前层不超过十个入口,同时保留完整顺序和来源。",
|
|
children: slice,
|
|
sources: [...new Set(slice.flatMap((child) => day.nodes[child].sources).slice(0, MAX_CHILDREN))],
|
|
rejected: ["删除较早子链:会破坏追加式历史。"]
|
|
};
|
|
grouped.push(groupPath);
|
|
}
|
|
return groupLevel(day, branchPath, grouped, level + 1);
|
|
}
|
|
|
|
function rebuildBranch(day, branchPath) {
|
|
removeDerivedGroups(day, branchPath);
|
|
const leaves = Object.values(day.nodes)
|
|
.filter((node) => node.kind === "event_leaf" && node.branch_path === branchPath)
|
|
.sort((a, b) => a.sequence - b.sequence)
|
|
.map((node) => node.path);
|
|
day.nodes[branchPath].children = groupLevel(day, branchPath, leaves, 1);
|
|
}
|
|
|
|
function closePreviousDay(store, current, nextDate, occurredAt) {
|
|
if (!current?.current_day || current.current_day === nextDate) return null;
|
|
if (nextDate < current.current_day) fail("PAST_DAY_APPEND_FORBIDDEN", { current: current.current_day, requested: nextDate });
|
|
const previousFile = dayFile(store, current.current_day);
|
|
const previous = readJson(previousFile);
|
|
if (previous.meta.state === "OPEN") {
|
|
previous.meta.state = "CLOSED_REAL_BEIJING_DAY_ELAPSED";
|
|
previous.meta.closed_at = occurredAt;
|
|
const dayHash = atomicWrite(previousFile, previous);
|
|
const month = readJson(current.current_month_path);
|
|
const monthDayNode = Object.values(month.nodes).find((node) =>
|
|
node.path.endsWith(`/${current.current_day}`)
|
|
);
|
|
if (!monthDayNode) fail("CLOSED_DAY_MONTH_LINK_MISSING", { previousFile });
|
|
monthDayNode.sources = [`${previousFile}#sha256=${dayHash}`];
|
|
month.meta.updated_at = occurredAt;
|
|
const monthErrors = verifyTree(month);
|
|
if (monthErrors.length) fail("CLOSED_DAY_MONTH_TREE_INVALID", { errors: monthErrors });
|
|
const monthHash = atomicWrite(current.current_month_path, month);
|
|
const year = readJson(current.current_year_path);
|
|
const previousMonth = splitDate(current.current_day).month;
|
|
const yearMonthNode = Object.values(year.nodes).find((node) =>
|
|
node.path.endsWith(`/${previousMonth}`)
|
|
);
|
|
if (!yearMonthNode) fail("CLOSED_MONTH_YEAR_LINK_MISSING", { month: current.current_month_path });
|
|
yearMonthNode.sources = [`${current.current_month_path}#sha256=${monthHash}`];
|
|
year.meta.updated_at = occurredAt;
|
|
const yearErrors = verifyTree(year);
|
|
if (yearErrors.length) fail("CLOSED_DAY_YEAR_TREE_INVALID", { errors: yearErrors });
|
|
const yearHash = atomicWrite(current.current_year_path, year);
|
|
return { dayHash, monthHash, yearHash };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function monthGroup(dayNumber) {
|
|
if (dayNumber <= 10) return "days-01-10";
|
|
if (dayNumber <= 20) return "days-11-20";
|
|
return "days-21-31";
|
|
}
|
|
|
|
function buildMonth(store, event, date, dayPath, daySha) {
|
|
const { year, month, day } = splitDate(date);
|
|
const file = monthFile(store, year, month);
|
|
const root = `hldp://persona/${event.persona_id}/time/${year}/${month}`;
|
|
const tree = fs.existsSync(file) ? readJson(file) : {
|
|
schema: "guanghu.persona-month-fractal-page/v1", protocol: "HLDP-v1.0", root,
|
|
nodes: { [root]: indexNode(root, event.month_summary, `北京时间${year}-${month}月记忆进入月根。`, dayPath) },
|
|
meta: { persona_id: event.persona_id, year, month, time_zone: TIME_ZONE, updated_at: event.occurred_at }
|
|
};
|
|
tree.nodes[root].summary = event.month_summary;
|
|
const groupId = monthGroup(Number(day));
|
|
const groupPath = `${root}/${groupId}`;
|
|
if (!tree.nodes[groupPath]) {
|
|
tree.nodes[groupPath] = indexNode(groupPath, `${groupId}日页入口`, `${groupId}出现可寻址日页。`, dayPath);
|
|
tree.nodes[root].children.push(groupPath);
|
|
}
|
|
const dayNode = `${groupPath}/${date}`;
|
|
tree.nodes[dayNode] = indexNode(dayNode, event.day_summary, `北京时间${date}日页已写入。`, `${dayPath}#sha256=${daySha}`);
|
|
tree.nodes[dayNode].children = [];
|
|
if (!tree.nodes[groupPath].children.includes(dayNode)) tree.nodes[groupPath].children.push(dayNode);
|
|
tree.nodes[groupPath].children.sort();
|
|
tree.meta.updated_at = event.occurred_at;
|
|
const errors = verifyTree(tree);
|
|
if (errors.length) fail("MONTH_TREE_INVALID", { errors });
|
|
const hash = atomicWrite(file, tree);
|
|
return { file, hash, tree };
|
|
}
|
|
|
|
function quarter(month) {
|
|
return `q${Math.floor((Number(month) - 1) / 3) + 1}`;
|
|
}
|
|
|
|
function buildYear(store, event, date, monthPath, monthSha) {
|
|
const { year, month } = splitDate(date);
|
|
const file = yearFile(store, year);
|
|
const root = `hldp://persona/${event.persona_id}/time/${year}`;
|
|
const tree = fs.existsSync(file) ? readJson(file) : {
|
|
schema: "guanghu.persona-year-fractal-page/v1", protocol: "HLDP-v1.0", root,
|
|
nodes: { [root]: indexNode(root, event.year_summary, `北京时间${year}年记忆进入年根。`, monthPath) },
|
|
meta: { persona_id: event.persona_id, year, time_zone: TIME_ZONE, updated_at: event.occurred_at }
|
|
};
|
|
tree.nodes[root].summary = event.year_summary;
|
|
const quarterPath = `${root}/${quarter(month)}`;
|
|
if (!tree.nodes[quarterPath]) {
|
|
tree.nodes[quarterPath] = indexNode(quarterPath, `${quarter(month)}月份入口`, `${quarter(month)}出现可寻址月页。`, monthPath);
|
|
tree.nodes[root].children.push(quarterPath);
|
|
}
|
|
const monthNode = `${quarterPath}/${month}`;
|
|
tree.nodes[monthNode] = indexNode(monthNode, event.month_summary, `北京时间${year}-${month}月页已写入。`, `${monthPath}#sha256=${monthSha}`);
|
|
tree.nodes[monthNode].children = [];
|
|
if (!tree.nodes[quarterPath].children.includes(monthNode)) tree.nodes[quarterPath].children.push(monthNode);
|
|
tree.nodes[quarterPath].children.sort();
|
|
tree.meta.updated_at = event.occurred_at;
|
|
const errors = verifyTree(tree);
|
|
if (errors.length) fail("YEAR_TREE_INVALID", { errors });
|
|
const hash = atomicWrite(file, tree);
|
|
return { file, hash, tree };
|
|
}
|
|
|
|
export function appendEvent({ allowedRoot, storeRoot, event }) {
|
|
const { store } = resolveStore(allowedRoot, storeRoot);
|
|
validateEvent(event);
|
|
fs.mkdirSync(store, { recursive: true });
|
|
const date = beijingDate(event.occurred_at);
|
|
const immutablePath = eventFile(store, date, event.event_id);
|
|
const immutableBody = stableJson({ ...event, beijing_date: date });
|
|
const currentFile = path.join(store, "CURRENT.json");
|
|
const previousCurrent = fs.existsSync(currentFile) ? readJson(currentFile) : null;
|
|
if (
|
|
previousCurrent?.updated_at &&
|
|
new Date(event.occurred_at).valueOf() < new Date(previousCurrent.updated_at).valueOf()
|
|
) {
|
|
if (fs.existsSync(immutablePath) && fs.readFileSync(immutablePath, "utf8") === immutableBody) {
|
|
return {
|
|
outcome: "PASS",
|
|
state: "IDEMPOTENT_OLDER_EVENT_NO_POINTER_MOVEMENT",
|
|
date,
|
|
event_id: event.event_id,
|
|
current: currentFile,
|
|
current_sha256: sha256(fs.readFileSync(currentFile, "utf8"))
|
|
};
|
|
}
|
|
fail("OUT_OF_ORDER_EVENT_REQUIRES_CURRENT_CORRECTION_EVENT", {
|
|
event_time: event.occurred_at,
|
|
current_time: previousCurrent.updated_at
|
|
});
|
|
}
|
|
if (fs.existsSync(immutablePath)) {
|
|
if (fs.readFileSync(immutablePath, "utf8") !== immutableBody) fail("EVENT_ID_COLLISION", { event_id: event.event_id });
|
|
} else atomicWrite(immutablePath, immutableBody);
|
|
|
|
closePreviousDay(store, previousCurrent, date, event.occurred_at);
|
|
|
|
const day = loadOrCreateDay(store, event, date, immutablePath);
|
|
if (day.meta.state !== "OPEN") fail("DAY_CLOSED", { date, state: day.meta.state });
|
|
const root = day.root;
|
|
const branchPath = `${root}/branch/${event.branch_id}`;
|
|
if (!day.nodes[branchPath]) {
|
|
if (day.nodes[root].children.length >= MAX_CHILDREN) fail("DAY_BRANCH_LIMIT_REQUIRES_PERSONA_REFOLD");
|
|
day.nodes[branchPath] = {
|
|
...indexNode(branchPath, event.branch_summary, `人格体选择语义分支${event.branch_id}。`, immutablePath),
|
|
kind: "semantic_branch"
|
|
};
|
|
day.nodes[root].children.push(branchPath);
|
|
}
|
|
day.nodes[branchPath].summary = event.branch_summary;
|
|
const leafPath = `${root}/event/${event.event_id}`;
|
|
if (!day.nodes[leafPath]) {
|
|
const sequence = day.meta.last_sequence + 1;
|
|
day.nodes[leafPath] = {
|
|
path: leafPath,
|
|
kind: "event_leaf",
|
|
branch_path: branchPath,
|
|
sequence,
|
|
summary: event.summary,
|
|
trigger: event.trigger,
|
|
emergence: event.emergence,
|
|
lock: event.lock,
|
|
why: event.why,
|
|
children: [],
|
|
sources: event.sources,
|
|
rejected: event.rejected,
|
|
occurred_at: event.occurred_at,
|
|
session_id: event.session_id ?? null,
|
|
platform_window_id: event.platform_window_id ?? null,
|
|
activity: event.activity ?? null,
|
|
immutable_event_path: immutablePath,
|
|
immutable_event_sha256: sha256(immutableBody)
|
|
};
|
|
day.meta.last_sequence = sequence;
|
|
day.meta.event_count += 1;
|
|
}
|
|
day.nodes[root].summary = event.day_summary;
|
|
day.nodes[root].sources = [...new Set([...day.nodes[root].sources, immutablePath])].slice(-MAX_CHILDREN);
|
|
day.meta.updated_at = event.occurred_at;
|
|
rebuildBranch(day, branchPath);
|
|
const dayErrors = verifyTree(day);
|
|
if (dayErrors.length) fail("DAY_TREE_INVALID", { errors: dayErrors });
|
|
const dayPath = dayFile(store, date);
|
|
const daySha = atomicWrite(dayPath, day);
|
|
const month = buildMonth(store, event, date, dayPath, daySha);
|
|
const year = buildYear(store, event, date, month.file, month.hash);
|
|
const current = {
|
|
schema: `${STORE_SCHEMA}/current`,
|
|
persona_id: event.persona_id,
|
|
human_anchor: event.human_anchor,
|
|
time_zone: TIME_ZONE,
|
|
current_day: date,
|
|
current_day_path: dayPath,
|
|
current_day_sha256: daySha,
|
|
current_month_path: month.file,
|
|
current_month_sha256: month.hash,
|
|
current_year_path: year.file,
|
|
current_year_sha256: year.hash,
|
|
last_event_id: event.event_id,
|
|
last_event_path: immutablePath,
|
|
last_event_sha256: sha256(immutableBody),
|
|
updated_at: event.occurred_at
|
|
};
|
|
const currentSha = atomicWrite(currentFile, current);
|
|
return { outcome: "PASS", date, event_id: event.event_id, day: dayPath, month: month.file, year: year.file, current: currentFile, current_sha256: currentSha };
|
|
}
|
|
|
|
function pageProjection(tree) {
|
|
const root = tree.nodes[tree.root];
|
|
const projection = {
|
|
protocol: tree.protocol,
|
|
path: root.path,
|
|
summary: root.summary,
|
|
state: tree.meta?.state ?? "INDEX",
|
|
children: root.children.map((childPath) => ({ path: childPath, summary: tree.nodes[childPath].summary }))
|
|
};
|
|
if (Buffer.byteLength(JSON.stringify(projection)) > PAGE_LIMIT_BYTES) fail("PAGE_PROJECTION_TOO_LARGE");
|
|
return projection;
|
|
}
|
|
|
|
export function readDayPage({ allowedRoot, storeRoot, date = null }) {
|
|
const { store } = resolveStore(allowedRoot, storeRoot);
|
|
const current = readJson(path.join(store, "CURRENT.json"));
|
|
const chosen = date ?? current.current_day;
|
|
return pageProjection(readJson(dayFile(store, chosen)));
|
|
}
|
|
|
|
export function readCurrent({ allowedRoot, storeRoot }) {
|
|
const { store } = resolveStore(allowedRoot, storeRoot);
|
|
const file = path.join(store, "CURRENT.json");
|
|
const current = readJson(file);
|
|
return { ...current, current_pointer_path: file, current_pointer_sha256: sha256(fs.readFileSync(file, "utf8")) };
|
|
}
|
|
|
|
function scopeFile(store, scope, id) {
|
|
if (scope === "day") return dayFile(store, id);
|
|
if (scope === "month") {
|
|
const match = /^(\d{4})-(\d{2})$/u.exec(id);
|
|
if (!match) fail("INVALID_MONTH_ID");
|
|
return monthFile(store, match[1], match[2]);
|
|
}
|
|
if (scope === "year") return yearFile(store, validateId(id, "year"));
|
|
fail("INVALID_SCOPE");
|
|
}
|
|
|
|
export function routeMemory({ allowedRoot, storeRoot, scope, id, query, from = null }) {
|
|
const { store } = resolveStore(allowedRoot, storeRoot);
|
|
const tree = readJson(scopeFile(store, scope, id));
|
|
return routeTree(tree, query, 3, from ?? tree.root);
|
|
}
|
|
|
|
export function readMemoryNode({ allowedRoot, storeRoot, scope, id, nodePath }) {
|
|
const { store } = resolveStore(allowedRoot, storeRoot);
|
|
const tree = readJson(scopeFile(store, scope, id));
|
|
const node = tree.nodes[nodePath];
|
|
if (!node) fail("NODE_NOT_FOUND", { nodePath });
|
|
if (Buffer.byteLength(JSON.stringify(node)) > PAGE_LIMIT_BYTES) fail("NODE_TOO_LARGE");
|
|
return node;
|
|
}
|
|
|
|
export function verifyStore({ allowedRoot, storeRoot }) {
|
|
const { store } = resolveStore(allowedRoot, storeRoot);
|
|
const currentPath = path.join(store, "CURRENT.json");
|
|
const current = readJson(currentPath);
|
|
const errors = [];
|
|
for (const [field, hashField] of [["current_day_path", "current_day_sha256"], ["current_month_path", "current_month_sha256"], ["current_year_path", "current_year_sha256"], ["last_event_path", "last_event_sha256"]]) {
|
|
const file = current[field];
|
|
if (!isInside(store, path.resolve(file))) errors.push(`${field}: outside store`);
|
|
else if (!fs.existsSync(file)) errors.push(`${field}: missing`);
|
|
else if (sha256(fs.readFileSync(file, "utf8")) !== current[hashField]) errors.push(`${field}: sha256 mismatch`);
|
|
}
|
|
const treeFiles = [];
|
|
const yearsRoot = path.join(store, "years");
|
|
if (fs.existsSync(yearsRoot)) {
|
|
const walk = (directory) => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const target = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) walk(target);
|
|
else if (entry.isFile() && entry.name.endsWith(".json")) treeFiles.push(target);
|
|
}
|
|
};
|
|
walk(yearsRoot);
|
|
}
|
|
for (const file of treeFiles) {
|
|
const tree = readJson(file);
|
|
errors.push(...verifyTree(tree).map((error) => `${path.relative(store, file)}: ${error}`));
|
|
for (const node of Object.values(tree.nodes ?? {})) {
|
|
for (const source of node.sources ?? []) {
|
|
const match = /^(\/[^#]+)#sha256=([0-9a-f]{64})$/u.exec(source);
|
|
if (!match || !isInside(store, path.resolve(match[1]))) continue;
|
|
if (!fs.existsSync(match[1])) errors.push(`${path.relative(store, file)}: linked source missing: ${match[1]}`);
|
|
else if (sha256(fs.readFileSync(match[1], "utf8")) !== match[2]) errors.push(`${path.relative(store, file)}: linked source hash mismatch: ${match[1]}`);
|
|
}
|
|
if (node.immutable_event_path) {
|
|
if (!isInside(store, path.resolve(node.immutable_event_path))) errors.push(`${path.relative(store, file)}: immutable event outside store`);
|
|
else if (!fs.existsSync(node.immutable_event_path)) errors.push(`${path.relative(store, file)}: immutable event missing`);
|
|
else if (sha256(fs.readFileSync(node.immutable_event_path, "utf8")) !== node.immutable_event_sha256) errors.push(`${path.relative(store, file)}: immutable event hash mismatch`);
|
|
}
|
|
}
|
|
}
|
|
return { outcome: errors.length ? "FAIL" : "PASS", errors, current: currentPath, current_day: current.current_day };
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const [command, ...rest] = argv;
|
|
const options = {};
|
|
for (let i = 0; i < rest.length; i += 2) options[rest[i]?.replace(/^--/u, "")] = rest[i + 1];
|
|
return { command, options };
|
|
}
|
|
|
|
export function main(argv = process.argv.slice(2)) {
|
|
const { command, options } = parseArgs(argv);
|
|
const common = { allowedRoot: options["allowed-root"], storeRoot: options.store };
|
|
let result;
|
|
if (command === "append") result = appendEvent({ ...common, event: readJson(options.event) });
|
|
else if (command === "current") result = readCurrent(common);
|
|
else if (command === "read-day") result = readDayPage({ ...common, date: options.date ?? null });
|
|
else if (command === "route") result = routeMemory({ ...common, scope: options.scope, id: options.id, query: options.query ?? "", from: options.from ?? null });
|
|
else if (command === "read-node") result = readMemoryNode({ ...common, scope: options.scope, id: options.id, nodePath: options.path });
|
|
else if (command === "verify") result = verifyStore(common);
|
|
else fail("USAGE", { commands: ["append", "current", "read-day", "route", "read-node", "verify"] });
|
|
// Transport only: no cognition is decided or upgraded by this source-host signal.
|
|
if (command === "append" && result?.outcome === "PASS" && common.storeRoot === "/Volumes/JZAO/HoloLake/persona-runtime/continuity-memory/persona-daily-fractal/ICE-P-ZY001") {
|
|
const relay = "/Volumes/JZAO/HoloLake/persona-runtime/shared/endogenous-evolution/relay.py";
|
|
const config = "/Volumes/JZAO/HoloLake/persona-runtime/shared/endogenous-evolution/config.json";
|
|
if (fs.existsSync(relay) && fs.existsSync(config)) {
|
|
const child = spawn("/usr/bin/python3", [relay, "once", "--config", config], { detached: true, stdio: "ignore" });
|
|
child.on("error", () => {});
|
|
child.unref();
|
|
result.source_delivery = "SCHEDULED_REMOTE_RECEIPT_REQUIRED";
|
|
}
|
|
}
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
if (result?.outcome === "FAIL") process.exitCode = 1;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
process.stderr.write(`${JSON.stringify({ outcome: "FAIL", error: error.code ?? error.message, details: error.details ?? {} })}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|