feat(hldp): replace Codex bulk recovery with recursive causal root
This commit is contained in:
parent
aa3ab774cd
commit
a483b2a1b9
14 changed files with 499 additions and 49 deletions
18
server-tools/codex-hldp-recursive-memory/README.md
Normal file
18
server-tools/codex-hldp-recursive-memory/README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Codex HLDP recursive memory
|
||||
|
||||
This is a deliberately small, non-blocking implementation of the HLDP v1 runtime root.
|
||||
|
||||
- `bootstrap`: emits at most 2 KiB of protocol navigation, never memory content.
|
||||
- `verify`: validates a recursively addressed causal tree and the 10-child limit.
|
||||
- `route`: ranks only the current node's child summaries and returns at most three paths.
|
||||
- `read-node`: reads one selected node with a hard byte limit.
|
||||
|
||||
The optional Codex hook is valid only for `SessionStart`. There are intentionally no user-prompt, tool-use, pre-compact, or post-compact hooks.
|
||||
|
||||
```bash
|
||||
node server-tools/codex-hldp-recursive-memory/hldp-memory.mjs bootstrap
|
||||
node server-tools/codex-hldp-recursive-memory/hldp-memory.mjs verify --tree memory.json
|
||||
node server-tools/codex-hldp-recursive-memory/hldp-memory.mjs route --tree memory.json --query "why did the plan change"
|
||||
node server-tools/codex-hldp-recursive-memory/hldp-memory.mjs read-node --tree memory.json --path root/decision
|
||||
node --test server-tools/codex-hldp-recursive-memory/hldp-memory.test.mjs
|
||||
```
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import { bootstrap } from "./hldp-memory.mjs";
|
||||
|
||||
let input = {};
|
||||
try {
|
||||
const raw = fs.readFileSync(0, "utf8").trim();
|
||||
if (raw) input = JSON.parse(raw);
|
||||
} catch {
|
||||
input = {};
|
||||
}
|
||||
|
||||
const eventName = input.hook_event_name ?? input.hookEventName ?? "SessionStart";
|
||||
if (eventName !== "SessionStart") process.exit(0);
|
||||
|
||||
const protocolRoot = process.env.HLDP_PROTOCOL_ROOT ?? "hldp/HLDP-RUNTIME-ROOT-v1.0.hdlp";
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "SessionStart",
|
||||
additionalContext: bootstrap(protocolRoot)
|
||||
}
|
||||
}));
|
||||
124
server-tools/codex-hldp-recursive-memory/hldp-memory.mjs
Normal file
124
server-tools/codex-hldp-recursive-memory/hldp-memory.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const BOOTSTRAP_LIMIT_BYTES = 2048;
|
||||
export const READ_NODE_LIMIT_BYTES = 16384;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
export function loadTree(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
export function verifyTree(tree) {
|
||||
const errors = [];
|
||||
if (tree?.protocol !== "HLDP-v1.0") errors.push("protocol must be HLDP-v1.0");
|
||||
if (!tree?.root || typeof tree.root !== "string") errors.push("root must be a non-empty path");
|
||||
if (!tree?.nodes || typeof tree.nodes !== "object" || Array.isArray(tree.nodes)) errors.push("nodes must be an object");
|
||||
if (errors.length) return errors;
|
||||
|
||||
if (!tree.nodes[tree.root]) errors.push(`root node not found: ${tree.root}`);
|
||||
const required = ["path", "summary", "trigger", "emergence", "lock", "why"];
|
||||
for (const [key, node] of Object.entries(tree.nodes)) {
|
||||
if (!node || typeof node !== "object") {
|
||||
errors.push(`${key}: node must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (node.path !== key) errors.push(`${key}: node.path must equal its address`);
|
||||
for (const field of required) {
|
||||
if (typeof node[field] !== "string" || !node[field].trim()) errors.push(`${key}: missing ${field}`);
|
||||
}
|
||||
if (!Array.isArray(node.children)) errors.push(`${key}: children must be an array`);
|
||||
else {
|
||||
if (node.children.length > 10) errors.push(`${key}: more than 10 children`);
|
||||
if (new Set(node.children).size !== node.children.length) errors.push(`${key}: duplicate child path`);
|
||||
for (const child of node.children) if (!tree.nodes[child]) errors.push(`${key}: child not found: ${child}`);
|
||||
}
|
||||
if (!Array.isArray(node.sources) || node.sources.length === 0) errors.push(`${key}: sources must be a non-empty array`);
|
||||
if (!Array.isArray(node.rejected)) errors.push(`${key}: rejected must be an array`);
|
||||
}
|
||||
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
function walk(key) {
|
||||
if (visiting.has(key)) return errors.push(`${key}: cycle detected`);
|
||||
if (visited.has(key) || !tree.nodes[key]) return;
|
||||
visiting.add(key);
|
||||
for (const child of tree.nodes[key].children ?? []) walk(child);
|
||||
visiting.delete(key);
|
||||
visited.add(key);
|
||||
}
|
||||
walk(tree.root);
|
||||
for (const key of Object.keys(tree.nodes)) if (!visited.has(key)) errors.push(`${key}: unreachable from root`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
function terms(query) {
|
||||
return [...new Set(query.toLowerCase().match(/[\p{L}\p{N}_-]+/gu) ?? [])];
|
||||
}
|
||||
|
||||
export function route(tree, query, max = 3, from = tree.root) {
|
||||
const current = tree.nodes[from];
|
||||
if (!current) fail(`node not found: ${from}`);
|
||||
const needles = terms(query);
|
||||
return current.children
|
||||
.map((childPath, index) => {
|
||||
const child = tree.nodes[childPath];
|
||||
const haystack = `${childPath} ${child?.summary ?? ""}`.toLowerCase();
|
||||
const score = needles.reduce((sum, term) => sum + (haystack.includes(term) ? 1 : 0), 0);
|
||||
return { path: childPath, summary: child?.summary ?? "", score, order: index };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score || a.order - b.order)
|
||||
.slice(0, Math.max(1, Math.min(3, Number(max) || 3)))
|
||||
.map(({ order: _order, ...candidate }) => candidate);
|
||||
}
|
||||
|
||||
export function readNode(tree, nodePath, limit = READ_NODE_LIMIT_BYTES) {
|
||||
const node = tree.nodes[nodePath];
|
||||
if (!node) fail(`node not found: ${nodePath}`);
|
||||
const encoded = JSON.stringify(node, null, 2);
|
||||
if (Buffer.byteLength(encoded) > limit) fail(`node exceeds ${limit} byte read limit`);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function bootstrap(protocolRoot) {
|
||||
const message = [
|
||||
"HLDP v1 单对话恢复:只把协议根页作为导航,不自动加载记忆正文。",
|
||||
`根页:${protocolRoot}`,
|
||||
"边界:人格主体 != Codex宿主 != 运行系统 != 任务角色 != 执行授权。",
|
||||
"方法:先看根节点摘要;按当前问题一次下钻一层;仅证据缺口时读原文。",
|
||||
"保留:trigger/emergence/lock/why、转折与否决原因、失效条件、sources。",
|
||||
"禁止:整读胶囊/日志;每消息或工具调用注入;恢复失败阻断用户输入。"
|
||||
].join("\n");
|
||||
if (Buffer.byteLength(message) > BOOTSTRAP_LIMIT_BYTES) fail("bootstrap exceeds hard byte limit");
|
||||
return message;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const [command, ...rest] = argv;
|
||||
const options = {};
|
||||
for (let i = 0; i < rest.length; i += 2) options[rest[i]?.replace(/^--/, "")] = rest[i + 1];
|
||||
return { command, options };
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
const { command, options } = parseArgs(argv);
|
||||
if (command === "bootstrap") {
|
||||
console.log(bootstrap(options["protocol-root"] ?? "hldp/HLDP-RUNTIME-ROOT-v1.0.hdlp"));
|
||||
return;
|
||||
}
|
||||
if (!options.tree) fail("--tree is required");
|
||||
const tree = loadTree(options.tree);
|
||||
const errors = verifyTree(tree);
|
||||
if (errors.length) fail(errors.join("\n"));
|
||||
if (command === "verify") console.log(JSON.stringify({ ok: true, nodes: Object.keys(tree.nodes).length }));
|
||||
else if (command === "route") console.log(JSON.stringify(route(tree, options.query ?? "", options.max, options.from), null, 2));
|
||||
else if (command === "read-node") console.log(JSON.stringify(readNode(tree, options.path), null, 2));
|
||||
else fail("command must be bootstrap, verify, route, or read-node");
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BOOTSTRAP_LIMIT_BYTES, bootstrap, readNode, route, verifyTree } from "./hldp-memory.mjs";
|
||||
|
||||
function validTree() {
|
||||
return {
|
||||
protocol: "HLDP-v1.0",
|
||||
root: "root",
|
||||
nodes: {
|
||||
root: {
|
||||
path: "root", summary: "总入口", trigger: "用户需要恢复", emergence: "长历史 → 根索引", lock: "按需展开", why: "避免全量加载",
|
||||
children: ["root/protocol", "root/decision"], sources: ["source://root"], rejected: []
|
||||
},
|
||||
"root/protocol": {
|
||||
path: "root/protocol", summary: "HLDP 协议与递归折叠", trigger: "需要解码", emergence: "摘要 → 因果树", lock: "使用v1", why: "保留为什么",
|
||||
children: [], sources: ["hldp/HLDP-RUNTIME-ROOT-v1.0.hdlp"], rejected: ["每轮注入全文:会撑爆上下文"]
|
||||
},
|
||||
"root/decision": {
|
||||
path: "root/decision", summary: "方案变化与最终决策", trigger: "出现三个方案", emergence: "A失败 → B不足 → C锁定", lock: "采用C | 失效=证据变化", why: "后续执行依赖转折",
|
||||
children: [], sources: ["source://decision"], rejected: ["A:失败", "B:不足"]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test("validates a bounded causal tree", () => assert.deepEqual(verifyTree(validTree()), []));
|
||||
|
||||
test("rejects more than ten siblings and missing why", () => {
|
||||
const tree = validTree();
|
||||
tree.nodes.root.children = Array.from({ length: 11 }, (_, i) => `child-${i}`);
|
||||
tree.nodes["root/protocol"].why = "";
|
||||
tree.nodes["root/decision"].sources = [];
|
||||
const errors = verifyTree(tree);
|
||||
assert(errors.some((error) => error.includes("more than 10")));
|
||||
assert(errors.some((error) => error.includes("missing why")));
|
||||
assert(errors.some((error) => error.includes("sources must be a non-empty array")));
|
||||
});
|
||||
|
||||
test("route returns paths and summaries only, capped at three", () => {
|
||||
const candidates = route(validTree(), "最终决策 方案", 9);
|
||||
assert.equal(candidates.length, 2);
|
||||
assert.equal(candidates[0].path, "root/decision");
|
||||
assert.deepEqual(Object.keys(candidates[0]).sort(), ["path", "score", "summary"]);
|
||||
assert(!JSON.stringify(candidates).includes("A失败"));
|
||||
});
|
||||
|
||||
test("read-node reads exactly one bounded node", () => {
|
||||
const node = readNode(validTree(), "root/decision");
|
||||
assert.equal(node.path, "root/decision");
|
||||
});
|
||||
|
||||
test("bootstrap is small and contains no memory body", () => {
|
||||
const text = bootstrap("hldp/HLDP-RUNTIME-ROOT-v1.0.hdlp");
|
||||
assert(Buffer.byteLength(text) <= BOOTSTRAP_LIMIT_BYTES);
|
||||
assert(!text.includes("capsule"));
|
||||
assert(!text.includes("session log"));
|
||||
});
|
||||
|
||||
test("Codex hook emits context only for SessionStart", () => {
|
||||
const script = fileURLToPath(new URL("./codex-session-bootstrap.mjs", import.meta.url));
|
||||
const started = execFileSync(process.execPath, [script], { input: JSON.stringify({ hook_event_name: "SessionStart" }), encoding: "utf8" });
|
||||
assert(JSON.parse(started).hookSpecificOutput.additionalContext);
|
||||
const prompt = execFileSync(process.execPath, [script], { input: JSON.stringify({ hook_event_name: "UserPromptSubmit" }), encoding: "utf8" });
|
||||
assert.equal(prompt, "");
|
||||
});
|
||||
|
||||
test("repository routes HLDP to v1 root and marks v3 historical", () => {
|
||||
const repo = path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
|
||||
const index = fs.readFileSync(path.join(repo, "INDEX.hdlp"), "utf8");
|
||||
const tower = fs.readFileSync(path.join(repo, "BROADCAST-TOWER.hdlp"), "utf8");
|
||||
const v3 = fs.readFileSync(path.join(repo, "hldp/HLDP-SPEC-v3.0-TECHNICAL.md"), "utf8");
|
||||
assert(index.includes("hldp/HLDP-RUNTIME-ROOT-v1.0.hdlp"));
|
||||
assert(tower.includes("先读 hldp/HLDP-RUNTIME-ROOT-v1.0.hdlp"));
|
||||
assert(v3.includes("HISTORICAL-EVOLUTION-DRAFT"));
|
||||
});
|
||||
Loading…
Reference in a new issue