Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions LifeOS/install/LIFEOS/PULSE/modules/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
statSync,
} from "node:fs";
import { join } from "node:path";
import { parseMemoryContent } from "../../TOOLS/MemoryWriter";

const HOME = process.env.HOME || "";
const CLAUDE = join(HOME, ".claude");
Expand Down Expand Up @@ -109,13 +110,9 @@ function safeReadJsonLines(path: string, lastN: number = 50): any[] {
function readMemoryFile(path: string): { entries: string[]; count: number; charsUsed: number } {
if (!existsSync(path)) return { entries: [], count: 0, charsUsed: 0 };
try {
const raw = readFileSync(path, "utf-8");
const start = raw.indexOf("<!-- BEGIN ENTRIES -->");
const end = raw.indexOf("<!-- END ENTRIES -->");
if (start === -1 || end === -1 || end < start) return { entries: [], count: 0, charsUsed: 0 };
const block = raw.slice(start + "<!-- BEGIN ENTRIES -->".length, end).trim();
if (!block) return { entries: [], count: 0, charsUsed: 0 };
const entries = block.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
// Shared lenient parser (MemoryWriter owns it) — the old strict indexOf
// slice showed an empty panel on marker-corrupted files.
const { entries } = parseMemoryContent(readFileSync(path, "utf-8"));
const charsUsed = entries.reduce((s, e) => s + e.length, 0);
return { entries, count: entries.length, charsUsed };
} catch {
Expand Down
61 changes: 59 additions & 2 deletions LifeOS/install/LIFEOS/TOOLS/MemoryHealthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@

import { existsSync, readFileSync, appendFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { parseMemoryContent, read as memoryRead, BEGIN_MARKER, END_MARKER } from "./MemoryWriter";

// CLI script, not a library: the checks below run at module top level and end
// in process.exit. Spawn it (MemoryHealthGate does); never import it.
if (!import.meta.main) {
throw new Error("MemoryHealthCheck.ts is a CLI script with top-level side effects — spawn it via bun, never import it.");
}

const HOME = process.env.HOME || "";
const CLAUDE = join(HOME, ".claude");
Expand Down Expand Up @@ -246,13 +253,63 @@ else add("principal-memory-present", "ok", "PRINCIPAL_MEMORY.md present.");
if (!existsSync(DA_MEM)) add("da-memory-missing", "critical", "DA_MEMORY.md missing.");
else add("da-memory-present", "ok", "DA_MEMORY.md present.");

// CHECK 7.5: marker structural sanity — exactly one BEGIN and one END, in order.
// The 2026-07 corruption (END-before-BEGIN + a duplicate-END stack growing +1
// per write) blinded every strict reader to 0 entries while cap-pressure kept
// reporting green headroom. Structural damage must be a red signal; the next
// canonical MemoryWriter write heals it.
for (const [label, path] of [["principal", PRINCIPAL_MEM], ["da", DA_MEM]] as const) {
if (!existsSync(path)) continue;
try {
const lines = readFileSync(path, "utf-8").split("\n").map(l => l.trim());
const begins = lines.filter(l => l === BEGIN_MARKER).length;
const ends = lines.filter(l => l === END_MARKER).length;
const ordered = lines.indexOf(BEGIN_MARKER) !== -1 && lines.indexOf(BEGIN_MARKER) < lines.indexOf(END_MARKER);
if (begins !== 1 || ends !== 1 || !ordered) {
add(`marker-corrupt:${label}`, "critical",
`${label} memory marker structure corrupt (${begins} BEGIN / ${ends} END${ordered ? "" : ", disordered"}) — awaiting canonical-write heal.`,
{ begins, ends });
} else {
add(`marker-ok:${label}`, "ok", `${label} memory marker pair canonical.`);
}
} catch (e) {
// A health probe must record its own failure, never kill the run and
// leave the previous healthy row standing.
add(`marker-check-error:${label}`, "critical", `${label} marker-sanity check failed to read the file: ${e instanceof Error ? e.message : String(e)}`);
}
}

// CHECK 7.6: pending silent loss — on-disk entries the writer-side read()
// excludes as invalid (over-length / marker-content / bad prefix). The
// reviewer's set-overwrite submits read() output, so anything reported here
// is erased by its next curation write; nag until the entry is repaired.
for (const [label, path] of [["principal", PRINCIPAL_MEM], ["da", DA_MEM]] as const) {
if (!existsSync(path)) continue;
let r: ReturnType<typeof memoryRead>;
try {
r = memoryRead(path);
} catch (e) {
add(`pending-loss-check-error:${label}`, "critical", `${label} pending-loss check failed to read the file: ${e instanceof Error ? e.message : String(e)}`);
continue;
}
if (!("dropped_invalid" in r)) continue;
if (r.dropped_invalid.length > 0) {
add(`pending-silent-loss:${label}`, "warn",
`${label} memory: ${r.dropped_invalid.length} on-disk entr${r.dropped_invalid.length === 1 ? "y" : "ies"} invalid to read() — the next curation write erases them: ${r.dropped_invalid.map(d => `[${d.reason}] ${d.entry.slice(0, 60)}…`).join(" · ")}`,
{ count: r.dropped_invalid.length });
} else {
add(`no-pending-loss:${label}`, "ok", `${label} memory: read() accepts all on-disk entries.`);
}
}

// CHECK 8: cap-pressure — the exact failure class that sat silent for two weeks.
// A file AT cap can't accept new memory; near-cap means the next curation must
// consolidate or it jams. (Eviction now works, so this is a warning not a freeze.)
function entryCount(path: string): number {
if (!existsSync(path)) return 0;
const m = readFileSync(path, "utf-8").match(/<!-- BEGIN ENTRIES -->([\s\S]*?)<!-- END ENTRIES -->/);
return m ? m[1].split("\n").map(l => l.trim()).filter(l => l.length > 0).length : 0;
// Shared lenient parser — the old lazy regex matched the first (empty)
// BEGIN→END pair on corrupted files and reported 0/48 headroom, green.
return parseMemoryContent(readFileSync(path, "utf-8")).entries.length;
}
for (const [label, path] of [["principal", PRINCIPAL_MEM], ["da", DA_MEM]] as const) {
const n = entryCount(path);
Expand Down
6 changes: 3 additions & 3 deletions LifeOS/install/LIFEOS/TOOLS/MemoryRestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { resolve as pathResolve } from "node:path";
import { homedir } from "node:os";
import { parseMemoryContent } from "./MemoryWriter";

const CLAUDE_ROOT = pathResolve(homedir(), ".claude");
const SNAPSHOT_DIR = pathResolve(CLAUDE_ROOT, "LIFEOS/MEMORY/OBSERVABILITY/memory-snapshots");
Expand All @@ -32,9 +33,8 @@ function snapshotsFor(which: string): string[] {
}

function countEntries(content: string): number {
const m = content.match(/<!-- BEGIN ENTRIES -->([\s\S]*?)<!-- END ENTRIES -->/);
if (!m) return 0;
return m[1].split("\n").map((l) => l.trim()).filter((l) => l.length > 0).length;
// Shared lenient parser — the old lazy regex read 0 on marker-corrupted files.
return parseMemoryContent(content).entries.length;
}

function main(): void {
Expand Down
264 changes: 264 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/MemoryWriter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/**
* MemoryWriter.test.ts — corruption-shape fixtures for the canonical-rebuild
* parser/serializer (task 359, 2026-07-23 marker-corruption fix).
*
* Write-path tests use temp fixtures via the constrained .memtest.md escape
* (OS temp dir only) — the LIVE memory files are never touched.
*/

import { describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
BEGIN_MARKER,
END_MARKER,
parseMemoryContent,
serializeMemoryContent,
setEntries,
read,
} from "./MemoryWriter";

const stripStamp = (s: string) => s.replace(/^last_updated: .*$/m, "last_updated: X");
const markerCount = (s: string, m: string) => s.split("\n").filter((l) => l.trim() === m).length;

const CORRUPTED = [
"---",
"schema_version: 1",
"last_updated: 2026-07-01T00:00:00.000Z",
"---",
"# Hot-Layer Memory",
"",
"<!-- template comment -->",
END_MARKER,
BEGIN_MARKER,
END_MARKER,
END_MARKER,
END_MARKER,
"FACT: legacy invalid-prefix orphan ~explicit",
"NAME: Fixture User",
"PREFERENCE: prefers fixtures over live files",
`RULE: keep the ${END_MARKER} marker pair intact`,
END_MARKER,
].join("\n") + "\n";

describe("parseMemoryContent — corrupted shapes", () => {
test("END-before-BEGIN + END stack: recovers all valid-prefix entries, keeps orphan in body", () => {
const p = parseMemoryContent(CORRUPTED);
expect(p.entries).toEqual([
"NAME: Fixture User",
"PREFERENCE: prefers fixtures over live files",
`RULE: keep the ${END_MARKER} marker pair intact`,
]);
expect(p.bodyLines.some((l) => l.trim().startsWith("FACT: "))).toBe(true);
expect(p.bodyLines.some((l) => l.trim() === BEGIN_MARKER || l.trim() === END_MARKER)).toBe(false);
});

test("entry containing a literal marker parses whole (no truncation)", () => {
const p = parseMemoryContent(CORRUPTED);
expect(p.entries[2]).toContain(END_MARKER);
});

test("CRLF file parses and canonicalizes with frontmatter intact", () => {
const crlf = "---\r\nschema_version: 1\r\n---\r\nNAME: Crlf User\r\n";
const p = parseMemoryContent(crlf);
expect(p.entries).toEqual(["NAME: Crlf User"]);
const s = serializeMemoryContent(p, p.entries, "test");
expect(s).toMatch(/^last_updated: /m);
expect(s).not.toContain("\r");
});

test("frontmatter-less file parses; serialize is stable", () => {
const bare = `NAME: Bare User\n${END_MARKER}\n`;
const p = parseMemoryContent(bare);
expect(p.frontmatter).toBe("");
expect(p.entries).toEqual(["NAME: Bare User"]);
const s1 = serializeMemoryContent(p, p.entries, "test");
const p2 = parseMemoryContent(s1);
const s2 = serializeMemoryContent(p2, p2.entries, "test");
expect(s1).toBe(s2);
});

test("frontmatter-only file yields empty entries and canonical empty block", () => {
const fmOnly = "---\nschema_version: 1\n---\n";
const p = parseMemoryContent(fmOnly);
expect(p.entries).toEqual([]);
const s = serializeMemoryContent(p, [], "test");
expect(markerCount(s, BEGIN_MARKER)).toBe(1);
expect(markerCount(s, END_MARKER)).toBe(1);
});
});

describe("serializeMemoryContent — canonical rebuild", () => {
test("one write converges any corruption to exactly one ordered marker pair", () => {
const p = parseMemoryContent(CORRUPTED);
const s = serializeMemoryContent(p, p.entries, "test");
expect(markerCount(s, BEGIN_MARKER)).toBe(1);
expect(markerCount(s, END_MARKER)).toBe(1);
const lines = s.split("\n").map((l) => l.trim());
expect(lines.indexOf(BEGIN_MARKER)).toBeLessThan(lines.indexOf(END_MARKER));
expect(s.endsWith("\n")).toBe(true);
});

test("idempotent: serialize∘parse∘serialize is byte-stable (modulo timestamp)", () => {
const p1 = parseMemoryContent(CORRUPTED);
const s1 = serializeMemoryContent(p1, p1.entries, "test");
const p2 = parseMemoryContent(s1);
const s2 = serializeMemoryContent(p2, p2.entries, "test");
expect(stripStamp(s2)).toBe(stripStamp(s1));
});

test("zero entry loss through canonicalization (verbatim set-diff empty)", () => {
const before = parseMemoryContent(CORRUPTED).entries;
const after = parseMemoryContent(
serializeMemoryContent(parseMemoryContent(CORRUPTED), before, "test"),
).entries;
expect(new Set(after)).toEqual(new Set(before));
});
});

describe("setEntries write path — temp fixtures only", () => {
const tmp = mkdtempSync(join(tmpdir(), "memwriter-test-"));

test("heals a corrupted on-disk file in one write, stable in two, orphan untouched", () => {
const f = join(tmp, "heal.memtest.md");
writeFileSync(f, CORRUPTED, "utf8");
const entries = parseMemoryContent(readFileSync(f, "utf8")).entries;

// Production path note: the reviewer submits read() output, which excludes
// the marker-substring entry BEFORE the write — the write-side
// dropped_malformed below only shows up because this test submits the raw
// parse. read().dropped_invalid is the signal production relies on.
const pre = read(f);
if ("dropped_invalid" in pre) {
expect(pre.dropped_invalid).toEqual([
{ entry: `RULE: keep the ${END_MARKER} marker pair intact`, reason: "malformed" },
]);
}

const w1 = setEntries(f, entries, { updatedBy: "test" });
expect(w1.ok).toBe(true);
if (w1.ok) {
expect(w1.accepted).toBe(2);
expect(w1.dropped_malformed).toBe(1);
}
const healed = readFileSync(f, "utf8");
expect(markerCount(healed, BEGIN_MARKER)).toBe(1);
expect(markerCount(healed, END_MARKER)).toBe(1);
expect(healed).toContain("FACT: legacy invalid-prefix orphan");
expect(healed).not.toContain(`RULE: keep the ${END_MARKER}`);

const w2 = setEntries(f, parseMemoryContent(healed).entries, { updatedBy: "test" });
expect(w2.ok).toBe(true);
expect(stripStamp(readFileSync(f, "utf8"))).toBe(stripStamp(healed));

const r = read(f);
expect("count" in r && r.count).toBe(2);
});

test("rejects a submitted entry containing marker text as malformed", () => {
const f = join(tmp, "marker-entry.memtest.md");
writeFileSync(f, `---\nx: 1\n---\n${BEGIN_MARKER}\n${END_MARKER}\n`, "utf8");
const w = setEntries(f, ["NAME: Ok", `RULE: sneaky ${BEGIN_MARKER} inside`], { updatedBy: "test" });
expect(w.ok).toBe(true);
if (w.ok) {
expect(w.accepted).toBe(1);
expect(w.dropped_malformed).toBe(1);
}
});

test("catastrophic-shrink guard still trips on near-wipe of a populated file", () => {
const f = join(tmp, "shrink.memtest.md");
const many = Array.from({ length: 12 }, (_, i) => `PREFERENCE: entry number ${i}`);
writeFileSync(f, `---\nx: 1\n---\n${BEGIN_MARKER}\n${many.join("\n")}\n${END_MARKER}\n`, "utf8");
const w = setEntries(f, ["PREFERENCE: entry number 0"], { updatedBy: "test" });
expect(w.ok).toBe(false);
if (!w.ok) expect(w.code).toBe("ESUSPECT_SHRINK");
});

test("temp escape stays confined: vault-shaped path outside tmpdir is rejected", () => {
const w = setEntries("/Users/laptop/Desktop/evil.memtest.md", ["NAME: x"], { updatedBy: "test" });
expect(w.ok).toBe(false);
if (!w.ok) expect(w.code).toBe("EINVAL_PATH");
});

test("temp escape stays confined: tmpdir symlink pointing outside is rejected", () => {
const link = join(tmp, "sneaky.memtest.md");
symlinkSync("/etc/hosts", link);
const w = setEntries(link, ["NAME: x"], { updatedBy: "test" });
expect(w.ok).toBe(false);
if (!w.ok) expect(w.code).toBe("EINVAL_PATH");
});

test("read() reports over-length on-disk entries as pending drops, not silently", () => {
const f = join(tmp, "overlength.memtest.md");
const big = `PREFERENCE: ${"Y".repeat(300)}`;
writeFileSync(f, `---\nx: 1\n---\n${BEGIN_MARKER}\nNAME: Ok\n${big}\n${END_MARKER}\n`, "utf8");
const r = read(f);
expect("dropped_invalid" in r).toBe(true);
if ("dropped_invalid" in r) {
expect(r.count).toBe(1);
expect(r.dropped_invalid).toEqual([{ entry: big, reason: "overlength" }]);
}
});

test("symlink at the .tmp write-target cannot be written through (O_EXCL)", () => {
const f = join(tmp, "wt.memtest.md");
const victim = join(tmp, "victim-not-memtest.txt");
writeFileSync(f, `---\nx: 1\n---\n${BEGIN_MARKER}\nNAME: Seed\n${END_MARKER}\n`, "utf8");
writeFileSync(victim, "ORIGINAL", "utf8");
symlinkSync(victim, `${f}.tmp`);
const w = setEntries(f, ["NAME: Seed", "RULE: written via the real file"], { updatedBy: "test" });
// The write must still succeed (O_EXCL unlinks the stale symlink first) and
// the victim must be untouched — never written through the symlink.
expect(w.ok).toBe(true);
expect(readFileSync(victim, "utf8")).toBe("ORIGINAL");
expect(readFileSync(f, "utf8")).toContain("RULE: written via the real file");
});

test("embedded-newline entry is rejected, keeping one entry = one line", () => {
const f = join(tmp, "nl.memtest.md");
writeFileSync(f, `---\nx: 1\n---\n${BEGIN_MARKER}\n${END_MARKER}\n`, "utf8");
const w = setEntries(f, ["NAME: A\nRULE: smuggled second line"], { updatedBy: "test" });
expect(w.ok).toBe(true);
if (w.ok) {
expect(w.accepted).toBe(0);
expect(w.dropped_malformed).toBe(1);
}
expect(read(f)).toMatchObject({ count: 0 });
});

test("near-total wipe with one added entry still trips the shrink guard", () => {
const f = join(tmp, "wipe.memtest.md");
const many = Array.from({ length: 20 }, (_, i) => `PREFERENCE: durable fact number ${i}`);
writeFileSync(f, `---\nx: 1\n---\n${BEGIN_MARKER}\n${many.join("\n")}\n${END_MARKER}\n`, "utf8");
// Keep 2 + add 1 rephrased = 3 new entries (defeats the old floor<3 + additions>0 escape).
const w = setEntries(f, [many[0], many[1], "PREFERENCE: a rephrased consolidation"], { updatedBy: "test" });
expect(w.ok).toBe(false);
if (!w.ok) expect(w.code).toBe("ESUSPECT_SHRINK");
});

test("cleanup", () => {
rmSync(tmp, { recursive: true, force: true });
expect(true).toBe(true);
});
});

describe("parseMemoryContent — frontmatter mis-close does not swallow the entries block", () => {
test("unterminated frontmatter closing on a body --- still recovers entries", () => {
// Opening --- with no proper close; a later body '---' would let the greedy
// frontmatter regex swallow the marker block. Demotion recovers the entries.
const bad = [
"---",
"schema_version: 1",
"",
BEGIN_MARKER,
"NAME: Should Be Found",
"RULE: also found",
END_MARKER,
"---",
].join("\n") + "\n";
const p = parseMemoryContent(bad);
expect(p.entries).toEqual(["NAME: Should Be Found", "RULE: also found"]);
});
});
Loading