Add typed self-state operation service

This commit is contained in:
Meta Vens 2026-05-16 12:06:28 +01:00
commit 064ca95f3e
7 changed files with 547 additions and 4 deletions

View file

@ -26,6 +26,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- The smallest coherent target is one polymorphic CultCache-backed state authority with typed document kinds plus typed mutation tools: agents propose small operations, and the state service validates/applies them. Whole-state JSON editing is rejected as a durable mutation path.
- Commit-one boundary naming is now in code at `packages/core/src/void-self-state-domain.ts`: it declares the global typed document kinds and strict operation payload schemas. Runtime behavior has not moved yet.
- Read-only projection is now in `packages/core/src/void-self-state-projection.ts`. `loadVoidSelfState` uses that typed projection for nap/reply-mode context while leaving the old richer prompt summary intact until typed documents explicitly own those details.
- Typed operations are now applied through `packages/core/src/void-self-state-service.ts` and `scripts/void-self-state.mjs apply-operation`. During migration this service writes the new typed documents and updates only the narrow legacy monolith fields still required by current runners.
- Most of the deterministic cleanup in `scripts/void-memory-organ.mjs` is recent compensator cruft from failed attempts to manually clean an exploded state file. Do not preserve it as an earned organ. Architect the state boundary so that cleanup pile is unnecessary.
- Sleep/distillation must be rewritten with a meaning-preservation contract: concrete subject, claim/question, evidence refs, live tension, and future-action implication survive compression. Bulk can die. Meaning does not get put through the office shredder and called a dream.
- The owner Codex lane is no longer one swollen file; orchestration, runtime/parsing, rendering, and shared helpers are split under `packages/providers/src/owner-codex-*.ts`.
@ -84,8 +85,8 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
## Likely Next Bounded Move
- Stop feature work on moderation, mood, agency, and public-lane behavior until the private-state boundary is rebuilt.
- First bounded move after the current commit: add the typed mutation CLI/service for the operations already named in `void-self-state-domain.ts`.
- Next cuts should delete unstable foundations early after the mutation service exists: move repo-activity cursor updates behind the typed store, replace whole-state agent JSON edits with operation output, remove `legacyJsonPath`/working-projection authority, then cut top-level mirror fields and cleanup code that only compensated for those broken boundaries.
- First bounded move after the current commit: move repo-activity cursor updates and delivered-reply receipts behind `scripts/void-self-state.mjs apply-operation`.
- Next cuts should delete unstable foundations early after concrete writers move: replace whole-state agent JSON edits with operation output, remove `legacyJsonPath`/working-projection authority, then cut top-level mirror fields and cleanup code that only compensated for those broken boundaries.
- Rebuild sleep/distillation before trusting it again. Fixture check: a memory like `AquariumSynthCSharp: Workflow cannot own the body` must survive sleep with its concrete subject, claim, evidence, tension, and implication intact.
- Do not build adapters around the JSON projection. That is how the heap learned to stand upright and ask for snacks.

View file

@ -13,3 +13,4 @@ export * from "./void-usage-rate-limiter";
export * from "./void-self-state-loader";
export * from "./void-self-state-domain";
export * from "./void-self-state-projection";
export * from "./void-self-state-service";

View file

@ -9,6 +9,8 @@ import {
} from "cultcache-ts";
import { z } from "zod";
import { voidSelfStateDocumentRegistry } from "./void-self-state-domain";
const MODERATION_STATE_DOCUMENT_TYPE = "voidbot.moderation_state";
const MODERATION_STATE_DOCUMENT_KEY = "voidbot.moderation_state/default";
@ -62,7 +64,7 @@ export const moderationStateSchema = z
})
.passthrough();
const moderationStateDocument = defineDocumentType({
export const moderationStateDocument = defineDocumentType({
type: MODERATION_STATE_DOCUMENT_TYPE,
schema: moderationStateSchema,
});
@ -296,6 +298,7 @@ export async function recordModerationDeliveryReceipt(
function createModerationStateCache(canonicalPath: string): CultCache {
const cache = new CultCache();
cache.registerDocumentType(moderationStateDocument);
cache.registerRegistry(voidSelfStateDocumentRegistry);
cache.addBackingStore(new SingleFileMessagePackBackingStore(canonicalPath));
return cache;
}

View file

@ -0,0 +1,459 @@
import { resolve } from "node:path";
import { CultCache, SingleFileMessagePackBackingStore } from "cultcache-ts";
import {
loadModerationState,
moderationStateDocument,
saveModerationState,
type ModerationState,
} from "./moderation-state-store";
import {
type VoidCandidateInterventions,
type VoidModerationCursor,
type VoidScheduledRuntime,
type VoidSelfStateOperation,
type VoidSpeechReceipts,
type VoidThoughtMemory,
voidCandidateInterventionsDocument,
voidModerationCursorDocument,
voidScheduledRuntimeDocument,
voidSelfProfileDocument,
voidSelfStateDocumentRegistry,
voidSelfStateOperationSchema,
voidSpeechReceiptsDocument,
voidThoughtMemoryDocument,
} from "./void-self-state-domain";
import {
projectModerationStateToTypedSelfState,
type VoidSelfStateTypedProjection,
} from "./void-self-state-projection";
type JsonObject = Record<string, unknown>;
export interface VoidSelfStateServiceOptions {
canonicalPath: string;
}
export interface VoidSelfStateOperationResult {
operation: VoidSelfStateOperation["operation"];
canonicalPath: string;
typedDocumentsWritten: number;
legacyCompatibilityUpdated: boolean;
}
export async function applyVoidSelfStateOperation(
options: VoidSelfStateServiceOptions,
rawOperation: unknown,
): Promise<VoidSelfStateOperationResult> {
const canonicalPath = resolve(options.canonicalPath);
const operation = voidSelfStateOperationSchema.parse(rawOperation);
const cache = createVoidSelfStateCache(canonicalPath);
await cache.pullAllBackingStores();
const legacyState = await loadModerationState(canonicalPath);
const typedState = readOrProjectTypedState(cache, legacyState);
applyTypedOperation(typedState, operation);
await writeTypedState(cache, typedState);
const legacyCompatibilityUpdated = applyLegacyCompatibilityOperation(legacyState, operation);
if (legacyCompatibilityUpdated) {
await saveModerationState(canonicalPath, legacyState);
}
return {
operation: operation.operation,
canonicalPath,
typedDocumentsWritten: 6,
legacyCompatibilityUpdated,
};
}
function createVoidSelfStateCache(canonicalPath: string): CultCache {
return CultCache.builder()
.withDocumentType(moderationStateDocument)
.withRegistry(voidSelfStateDocumentRegistry)
.withGenericStore(new SingleFileMessagePackBackingStore(canonicalPath))
.build();
}
function readOrProjectTypedState(
cache: CultCache,
legacyState: ModerationState,
): VoidSelfStateTypedProjection {
const projected = projectModerationStateToTypedSelfState(legacyState);
return {
selfProfile: cache.getGlobal(voidSelfProfileDocument) ?? projected.selfProfile,
moderationCursor: cache.getGlobal(voidModerationCursorDocument) ?? projected.moderationCursor,
speechReceipts: cache.getGlobal(voidSpeechReceiptsDocument) ?? projected.speechReceipts,
thoughtMemory: cache.getGlobal(voidThoughtMemoryDocument) ?? projected.thoughtMemory,
scheduledRuntime: cache.getGlobal(voidScheduledRuntimeDocument) ?? projected.scheduledRuntime,
candidateInterventions:
cache.getGlobal(voidCandidateInterventionsDocument) ?? projected.candidateInterventions,
};
}
async function writeTypedState(
cache: CultCache,
state: VoidSelfStateTypedProjection,
): Promise<void> {
await cache.putGlobal(voidSelfProfileDocument, stripUndefined(state.selfProfile));
await cache.putGlobal(voidModerationCursorDocument, stripUndefined(state.moderationCursor));
await cache.putGlobal(voidSpeechReceiptsDocument, stripUndefined(state.speechReceipts));
await cache.putGlobal(voidThoughtMemoryDocument, stripUndefined(state.thoughtMemory));
await cache.putGlobal(voidScheduledRuntimeDocument, stripUndefined(state.scheduledRuntime));
await cache.putGlobal(voidCandidateInterventionsDocument, stripUndefined(state.candidateInterventions));
}
function applyTypedOperation(
state: VoidSelfStateTypedProjection,
operation: VoidSelfStateOperation,
): void {
switch (operation.operation) {
case "record_reviewed_messages":
state.moderationCursor.lastReviewedMessageId = operation.lastReviewedMessageId;
state.moderationCursor.lastReviewedTimestamp = operation.lastReviewedTimestamp;
state.moderationCursor.updatedAt = operation.lastReviewedTimestamp;
return;
case "upsert_open_case":
upsertBy(
state.moderationCursor.openCases,
operation.case,
(entry) => entry.sourceMessageId,
);
state.moderationCursor.updatedAt = operation.case.lastTouchedAt;
return;
case "close_open_case":
closeTypedOpenCase(state.moderationCursor, operation);
return;
case "update_repo_activity_cursor":
upsertBy(state.moderationCursor.repoActivityCursor, operation.cursor, (entry) => entry.repo.toLowerCase());
state.moderationCursor.updatedAt = operation.cursor.updatedAt;
return;
case "record_delivery_receipt":
upsertBy(state.speechReceipts.recentReceipts, operation.receipt, (entry) => entry.receiptKey);
state.speechReceipts.recentReceipts = state.speechReceipts.recentReceipts
.sort((left, right) => left.sentAt.localeCompare(right.sentAt))
.slice(-24);
state.speechReceipts.updatedAt = operation.receipt.sentAt;
closeCasesForReceipt(state.moderationCursor, operation.receipt);
return;
case "append_distilled_memory":
upsertBy(state.thoughtMemory.memories, operation.memory, (entry) => entry.memoryId);
state.thoughtMemory.updatedAt = operation.memory.updatedAt;
return;
case "merge_incubation_support":
upsertBy(state.thoughtMemory.incubation, operation.thread, (entry) => entry.threadId);
state.thoughtMemory.updatedAt = operation.thread.updatedAt;
return;
case "queue_candidate_intervention":
upsertBy(state.candidateInterventions.interventions, operation.intervention, (entry) => entry.interventionId);
state.candidateInterventions.updatedAt = operation.intervention.updatedAt;
return;
case "retire_candidate_intervention":
retireCandidateIntervention(state.candidateInterventions, operation);
return;
case "update_sleep_cycle":
state.scheduledRuntime.sleepCycle = operation.sleepCycle;
state.scheduledRuntime.updatedAt = new Date().toISOString();
return;
case "update_speaking_pressure":
state.scheduledRuntime.speakingPressure = operation.speakingPressure;
state.scheduledRuntime.updatedAt = new Date().toISOString();
return;
case "propose_memory_distillation":
return;
case "apply_memory_distillation":
upsertBy(state.thoughtMemory.memories, operation.memory, (entry) => entry.memoryId);
state.thoughtMemory.memories = state.thoughtMemory.memories.filter(
(entry) => !operation.sourceMemoryIds.includes(entry.memoryId) || entry.memoryId === operation.memory.memoryId,
);
state.thoughtMemory.updatedAt = operation.appliedAt;
return;
}
}
function applyLegacyCompatibilityOperation(
legacyState: ModerationState,
operation: VoidSelfStateOperation,
): boolean {
const legacy = legacyState as unknown as JsonObject;
const runtime = ensureObject(legacy, "moderation_runtime");
switch (operation.operation) {
case "record_reviewed_messages":
runtime.cursor = {
lastReviewedMessageId: operation.lastReviewedMessageId,
lastReviewedTimestamp: operation.lastReviewedTimestamp,
};
return true;
case "upsert_open_case":
upsertLegacyRuntimeArray(runtime, "open_cases", operation.case, "sourceMessageId");
return true;
case "close_open_case":
closeLegacyOpenCase(runtime, operation);
return true;
case "update_repo_activity_cursor": {
const repoCursor = ensureObject(runtime, "repo_activity_cursor");
repoCursor[operation.cursor.repo] = {
lastSeenHash: operation.cursor.lastCommitSha ?? null,
lastSeenCommittedAt: operation.cursor.lastCommitAt ?? null,
lastInjectedAt: operation.cursor.updatedAt,
};
return true;
}
case "record_delivery_receipt":
upsertLegacyRuntimeArray(runtime, "recent_delivery_receipts", operation.receipt, "receiptKey");
closeLegacyOpenCaseForReceipt(runtime, operation.receipt);
return true;
case "queue_candidate_intervention":
upsertLegacyRuntimeArray(runtime, "candidate_interventions", operation.intervention, "interventionId");
return true;
case "retire_candidate_intervention":
retireLegacyCandidate(runtime, operation);
return true;
case "update_sleep_cycle":
runtime.sleep_cycle = operation.sleepCycle;
return true;
case "update_speaking_pressure":
runtime.speaking_bias = operation.speakingPressure;
return true;
case "append_distilled_memory":
case "apply_memory_distillation":
upsertLegacyMemory(legacy, operation.memory);
return true;
case "merge_incubation_support":
ensureObject(runtime, "incubation");
upsertLegacyNestedArray(runtime, ["incubation"], "active_thoughts", operation.thread, "threadId");
return true;
case "propose_memory_distillation":
return false;
}
}
function closeTypedOpenCase(
cursor: VoidModerationCursor,
operation: Extract<VoidSelfStateOperation, { operation: "close_open_case" }>,
): void {
for (const openCase of cursor.openCases) {
if (openCase.sourceMessageId !== operation.sourceMessageId) {
continue;
}
openCase.status = operation.status;
openCase.resolvedAt = operation.resolvedAt;
openCase.lastTouchedAt = operation.resolvedAt;
openCase.resolutionSummary = operation.resolutionSummary;
}
cursor.updatedAt = operation.resolvedAt;
}
function closeCasesForReceipt(
cursor: VoidModerationCursor,
receipt: VoidSpeechReceipts["recentReceipts"][number],
): void {
if (!receipt.replyToMessageId) {
return;
}
for (const openCase of cursor.openCases) {
if (openCase.sourceMessageId !== receipt.replyToMessageId || isTerminalCaseStatus(openCase.status)) {
continue;
}
openCase.status = "answered";
openCase.resolvedAt = receipt.sentAt;
openCase.lastTouchedAt = receipt.sentAt;
openCase.resolutionSummary = receipt.preview
? `Answered in-channel: ${receipt.preview}`
: "Answered in-channel.";
}
cursor.updatedAt = receipt.sentAt;
}
function retireCandidateIntervention(
candidates: VoidCandidateInterventions,
operation: Extract<VoidSelfStateOperation, { operation: "retire_candidate_intervention" }>,
): void {
for (const intervention of candidates.interventions) {
if (intervention.interventionId !== operation.interventionId) {
continue;
}
intervention.status = "retired";
intervention.retiredAt = operation.retiredAt;
intervention.updatedAt = operation.retiredAt;
intervention.tags = [...new Set([...intervention.tags, `retired:${operation.reason}`])];
}
candidates.updatedAt = operation.retiredAt;
}
function upsertBy<T>(entries: T[], value: T, keyOf: (value: T) => string): void {
const key = keyOf(value);
const index = entries.findIndex((entry) => keyOf(entry) === key);
if (index === -1) {
entries.push(value);
} else {
entries[index] = value;
}
}
function upsertLegacyRuntimeArray(
runtime: JsonObject,
key: string,
value: unknown,
idKey: string,
): void {
const entries = readObjectArray(runtime[key]);
const id = isObject(value) ? readString(value, idKey) : undefined;
if (!id) {
return;
}
const index = entries.findIndex((entry) => readString(entry, idKey) === id);
if (index === -1) {
entries.push(value as JsonObject);
} else {
entries[index] = value as JsonObject;
}
runtime[key] = entries;
}
function upsertLegacyNestedArray(
root: JsonObject,
objectPath: string[],
key: string,
value: unknown,
idKey: string,
): void {
let container = root;
for (const segment of objectPath) {
container = ensureObject(container, segment);
}
upsertLegacyRuntimeArray(container, key, value, idKey);
}
function closeLegacyOpenCase(
runtime: JsonObject,
operation: Extract<VoidSelfStateOperation, { operation: "close_open_case" }>,
): void {
const entries = readObjectArray(runtime.open_cases);
for (const entry of entries) {
if (readString(entry, "sourceMessageId") !== operation.sourceMessageId) {
continue;
}
entry.status = operation.status;
entry.resolvedAt = operation.resolvedAt;
entry.lastTouchedAt = operation.resolvedAt;
entry.resolutionSummary = operation.resolutionSummary;
}
runtime.open_cases = entries;
}
function closeLegacyOpenCaseForReceipt(
runtime: JsonObject,
receipt: VoidSpeechReceipts["recentReceipts"][number],
): void {
if (!receipt.replyToMessageId) {
return;
}
const entries = readObjectArray(runtime.open_cases);
for (const entry of entries) {
if (readString(entry, "sourceMessageId") !== receipt.replyToMessageId) {
continue;
}
const status = readString(entry, "status");
if (status && ["answered", "resolved", "closed", "retired", "dropped"].includes(status)) {
continue;
}
entry.status = "answered";
entry.resolvedAt = receipt.sentAt;
entry.lastTouchedAt = receipt.sentAt;
entry.resolutionSummary = receipt.preview
? `Answered in-channel: ${receipt.preview}`
: "Answered in-channel.";
}
runtime.open_cases = entries;
}
function retireLegacyCandidate(
runtime: JsonObject,
operation: Extract<VoidSelfStateOperation, { operation: "retire_candidate_intervention" }>,
): void {
const entries = readObjectArray(runtime.candidate_interventions);
for (const entry of entries) {
if (readString(entry, "interventionId") !== operation.interventionId) {
continue;
}
entry.status = "retired";
entry.retiredAt = operation.retiredAt;
entry.updatedAt = operation.retiredAt;
entry.retirementReason = operation.reason;
}
runtime.candidate_interventions = entries;
}
function upsertLegacyMemory(legacy: JsonObject, memory: VoidThoughtMemory["memories"][number]): void {
const memories = ensureObject(legacy, "memories");
const semantic = readObjectArray(memories.semantic);
const projected = {
memoryId: memory.memoryId,
kind: memory.kind,
subjectId: memory.target.id,
subjectLabel: memory.target.label ?? memory.target.id,
summary: memory.summary,
evidenceRefs: memory.evidenceRefs.map((entry) => entry.ref),
lastObservedAt: memory.updatedAt,
confidence: 0.8,
};
const index = semantic.findIndex((entry) => readString(entry, "memoryId") === memory.memoryId);
if (index === -1) {
semantic.push(projected);
} else {
semantic[index] = { ...semantic[index], ...projected };
}
memories.semantic = semantic;
}
function ensureObject(root: JsonObject, key: string): JsonObject {
const existing = root[key];
if (isObject(existing)) {
return existing;
}
const next: JsonObject = {};
root[key] = next;
return next;
}
function readObjectArray(value: unknown): JsonObject[] {
return Array.isArray(value) ? value.filter(isObject) : [];
}
function readString(value: JsonObject, key: string): string | undefined {
const candidate = value[key];
return typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : undefined;
}
function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isTerminalCaseStatus(status: string): boolean {
return ["answered", "resolved", "closed", "retired", "dropped"].includes(status);
}
function stripUndefined<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((entry) => stripUndefined(entry)) as T;
}
if (!isObject(value)) {
return value;
}
const next: JsonObject = {};
for (const [key, child] of Object.entries(value)) {
if (child === undefined) {
continue;
}
next[key] = stripUndefined(child);
}
return next as T;
}

View file

@ -0,0 +1,78 @@
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const require = createRequire(import.meta.url);
const coreDistPath = resolve(repoRoot, "packages/core/dist/index.js");
if (!existsSync(coreDistPath)) {
throw new Error(`Missing built core package at ${coreDistPath}. Run npm run build first.`);
}
const { applyVoidSelfStateOperation } = require(coreDistPath);
async function main() {
const [command, ...rest] = process.argv.slice(2);
const args = parseArgs(rest);
if (command !== "apply-operation") {
throw new Error("Expected command: apply-operation.");
}
const canonicalPath = requirePathArg(args, "canonical");
const operation = readOperation(args);
const result = await applyVoidSelfStateOperation({ canonicalPath }, operation);
process.stdout.write(`${JSON.stringify(result)}\n`);
}
function readOperation(args) {
if (typeof args.operation === "string" && args.operation.trim().length > 0) {
return JSON.parse(args.operation);
}
if (typeof args["operation-file"] === "string" && args["operation-file"].trim().length > 0) {
return JSON.parse(stripLeadingBom(readFileSync(resolve(args["operation-file"]), "utf8")));
}
throw new Error("apply-operation requires --operation JSON or --operation-file path.");
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) {
continue;
}
const key = token.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args[key] = "true";
continue;
}
args[key] = next;
index += 1;
}
return args;
}
function requirePathArg(args, key) {
const value = args[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Missing required --${key} path.`);
}
return resolve(value);
}
function stripLeadingBom(input) {
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
}
await main();

View file

@ -91,3 +91,4 @@
{"ts":"2026-05-16T08:06:04Z","type":"research","status":"ok","note":"Clarified the teardown target: most deterministic cleanup in the memory organ is recent compensator cruft caused by failed attempts to manually clean an exploded moderation state file. The rebuild should prevent bad state from entering through typed CultCache operations, delete compensating cleanup paths as boundaries improve, and rewrite sleep/distillation with a meaning-preservation contract so concrete claims/evidence/tensions survive compression instead of becoming generic abstraction sludge."}
{"ts":"2026-05-16T10:03:21Z","type":"implementation","status":"ok","note":"The typed private self-state boundary now exists as code in packages/core/src/void-self-state-domain.ts: one polymorphic CultCache registry names global document kinds for self profile, moderation cursor/open cases/repo cursor, speech receipts, thought memory/incubation, scheduled runtime, and candidate interventions, plus strict operation payload schemas. This is Commit 1 territory only; runtime still uses the old moderation state store until read-only projections and mutation application are built."}
{"ts":"2026-05-16T11:01:13Z","type":"implementation","status":"ok","note":"Read-only typed self-state projection now exists in packages/core/src/void-self-state-projection.ts. The direct self-state loader derives nap/reply-mode projection from typed scheduled-runtime and thought-memory documents while preserving the older rich prompt summary until the new document model explicitly owns those details. Template and temp CultCache smoke checks passed."}
{"ts":"2026-05-16T11:06:01Z","type":"implementation","status":"ok","note":"Typed self-state mutation service and scripts/void-self-state.mjs now apply strict operation payloads to the polymorphic CultCache file. During migration, the service writes typed global documents and updates the narrow legacy monolith fields current scripts still read; this dual write is centralized in one service, not spread across projection-file editors. Temp-store CLI and repo-cursor operation smokes passed."}

View file

@ -149,4 +149,4 @@ rejected_paths:
current_status:
summary: VoidBot's ordinary bot/worker/provider/RAG/Postgres/Qdrant structure is still conceptually sound, but the moderation/mood/private-self-state foundation has become architectural Jenga. It currently depends on canonical MessagePack plus an editable JSON projection, broad passthrough schemas, legacy mirror fields, a giant memory-organ cleanup pass, and a prompt-owned scheduled runner. Much of the cleanup pass is recent compensator cruft from attempts to manually clean exploded state; it should not be dignified into permanent architecture.
next_action: The typed CultCache self-state document vocabulary and read-only projection layer now exist in core, and direct reply nap/reply-mode projection is derived from typed scheduled-runtime/thought-memory views. Next add a typed mutation service/CLI for cursor, open cases, delivery receipts, repo cursor, candidates, sleep/speaking pressure, and distilled memory, then move repo cursor/open-case/receipt/candidate/memory updates behind it. Delete JSON projection authority and legacy mirrors early instead of adding adapters around them. Redesign sleep/distillation around a meaning-preservation contract before trusting it to compress memory.
next_action: The typed CultCache self-state document vocabulary, read-only projection layer, and operation mutation CLI/service now exist. Next move concrete writers behind those operations, starting with repo activity cursor and delivery receipts, then replace whole-state agent JSON edits with operation output. Delete JSON projection authority and legacy mirrors early instead of adding adapters around them. Redesign sleep/distillation around a meaning-preservation contract before trusting it to compress memory.