Route moderation cursor and receipts through typed operations

This commit is contained in:
Meta Vens 2026-05-16 12:14:40 +01:00
commit 82f35b22a0
7 changed files with 76 additions and 223 deletions

View file

@ -28,6 +28,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- 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.
- `scripts/export-recent-repo-activity.mjs` no longer writes the JSON working projection for cursor advancement. It applies `update_repo_activity_cursor` through `scripts/void-self-state.mjs`; old `--cursor-file .json` arguments are only translated to the sibling `.msgpack` path for compatibility.
- `scripts/run-void-moderator-rumination.ps1` now records reviewed Discord cursors and speech delivery receipts through typed self-state operations. The old `set-cursor` and `record-delivery-receipt` commands were removed from `scripts/moderation-state-store.mjs`.
- 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`.
@ -86,7 +87,7 @@ 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: move delivered-reply receipts behind `scripts/void-self-state.mjs apply-operation`.
- First bounded move after the current commit: replace the unattended child agent's whole-state JSON editing with operation output that the runner applies through `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

@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
@ -223,78 +222,6 @@ export async function readModerationStateCursor(canonicalPath: string): Promise<
};
}
export async function setModerationStateCursor(
paths: ModerationStatePaths,
cursor: {
lastReviewedMessageId?: string | null;
lastReviewedTimestamp?: string | null;
},
): Promise<ModerationState> {
const canonicalPath = resolve(paths.canonicalPath);
const workingPath = resolve(paths.workingPath);
const state = await loadModerationState(canonicalPath);
state.moderation_runtime.cursor = {
lastReviewedMessageId: cursor.lastReviewedMessageId ?? null,
lastReviewedTimestamp: cursor.lastReviewedTimestamp ?? null,
};
await saveModerationState(canonicalPath, state);
await materializeModerationStateWorkingView({ state, workingPath });
return state;
}
export interface ModerationDeliveryReceipt {
sentAt: string;
mode?: string | null;
transport?: string | null;
channelId?: string | null;
replyToMessageId?: string | null;
personaName?: string | null;
personaAvatarUrl?: string | null;
contentLength?: number | null;
chunkCount?: number | null;
preview?: string | null;
}
export async function recordModerationDeliveryReceipt(
paths: ModerationStatePaths,
receipt: ModerationDeliveryReceipt,
): Promise<ModerationState> {
const canonicalPath = resolve(paths.canonicalPath);
const workingPath = resolve(paths.workingPath);
const state = await loadModerationState(canonicalPath);
const runtime = cloneJson(state.moderation_runtime);
const runtimeRecord = runtime as Record<string, unknown>;
const receipts = readArray(runtimeRecord, "recent_delivery_receipts")
.filter(isObject)
.filter((entry): entry is Record<string, unknown> => !!readString(entry, "sentAt"));
const normalizedReceipt = normalizeDeliveryReceipt(receipt);
const receiptKey = readString(normalizedReceipt, "receiptKey");
const existing = receipts.find(
(entry) =>
readString(entry, "receiptKey") === receiptKey ||
(readString(normalizedReceipt, "replyToMessageId") &&
readString(entry, "replyToMessageId") ===
readString(normalizedReceipt, "replyToMessageId") &&
readString(entry, "previewHash") === readString(normalizedReceipt, "previewHash")),
);
if (existing) {
Object.assign(existing, normalizedReceipt);
} else {
receipts.push(normalizedReceipt);
}
runtimeRecord.recent_delivery_receipts = receipts
.sort(compareDeliveryReceiptsBySentAt)
.slice(-24);
closeAnsweredOpenCases(runtimeRecord, normalizedReceipt);
state.moderation_runtime = runtime;
await saveModerationState(canonicalPath, state);
await materializeModerationStateWorkingView({ state, workingPath });
return state;
}
function createModerationStateCache(canonicalPath: string): CultCache {
const cache = new CultCache();
cache.registerDocumentType(moderationStateDocument);
@ -303,81 +230,6 @@ function createModerationStateCache(canonicalPath: string): CultCache {
return cache;
}
function normalizeDeliveryReceipt(receipt: ModerationDeliveryReceipt) {
const sentAt = receipt.sentAt;
const replyToMessageId = receipt.replyToMessageId ?? null;
const preview = receipt.preview?.trim() ?? null;
const previewHash = preview ? sha1(preview) : null;
const receiptKey = sha1(
JSON.stringify({
sentAt,
channelId: receipt.channelId ?? null,
replyToMessageId,
previewHash,
mode: receipt.mode ?? null,
}),
);
return {
sentAt,
mode: receipt.mode ?? null,
transport: receipt.transport ?? null,
channelId: receipt.channelId ?? null,
replyToMessageId,
personaName: receipt.personaName ?? null,
personaAvatarUrl: receipt.personaAvatarUrl ?? null,
contentLength: receipt.contentLength ?? null,
chunkCount: receipt.chunkCount ?? null,
preview,
previewHash,
receiptKey,
};
}
function compareDeliveryReceiptsBySentAt(left: unknown, right: unknown) {
const leftSentAt = isObject(left) ? readString(left, "sentAt") ?? "" : "";
const rightSentAt = isObject(right) ? readString(right, "sentAt") ?? "" : "";
return leftSentAt.localeCompare(rightSentAt);
}
function closeAnsweredOpenCases(runtime: Record<string, unknown>, receipt: Record<string, unknown>) {
const replyToMessageId = readString(receipt, "replyToMessageId");
if (!replyToMessageId) {
return;
}
const preview = readString(receipt, "preview");
const sentAt = readString(receipt, "sentAt");
const openCases = readArray(runtime, "open_cases").filter(isObject);
for (const openCase of openCases) {
const sourceMessageId = readString(openCase, "sourceMessageId");
const status = readString(openCase, "status");
if (sourceMessageId !== replyToMessageId) {
continue;
}
if (status && ["answered", "resolved", "closed", "retired", "dropped"].includes(status)) {
continue;
}
openCase.status = "answered";
openCase.resolvedAt = sentAt ?? null;
openCase.lastTouchedAt = sentAt ?? null;
openCase.resolutionSummary =
preview && preview.length > 0
? `Answered in-channel: ${preview}`
: "Answered in-channel.";
}
runtime.open_cases = openCases;
}
function sha1(value: string) {
return createHash("sha1").update(value).digest("hex");
}
function reattachSemanticVectors(previous: ModerationState, next: ModerationState): ModerationState {
const previousVectors = collectSemanticVectors(previous);
const merged = cloneJson(next);

View file

@ -204,6 +204,10 @@ function applyLegacyCompatibilityOperation(
}
case "record_delivery_receipt":
upsertLegacyRuntimeArray(runtime, "recent_delivery_receipts", operation.receipt, "receiptKey");
runtime.recent_delivery_receipts = readObjectArray(runtime.recent_delivery_receipts)
.filter((entry) => readString(entry, "sentAt"))
.sort((left, right) => (readString(left, "sentAt") ?? "").localeCompare(readString(right, "sentAt") ?? ""))
.slice(-24);
closeLegacyOpenCaseForReceipt(runtime, operation.receipt);
return true;
case "queue_candidate_intervention":

View file

@ -16,9 +16,7 @@ const {
ensureModerationStateStore,
getModerationStateLegacyJsonPath,
getModerationStateWorkingPath,
recordModerationDeliveryReceipt,
readModerationStateCursor,
setModerationStateCursor,
} = require(coreDistPath);
async function main() {
@ -66,44 +64,6 @@ async function main() {
});
return;
}
case "set-cursor": {
const state = await setModerationStateCursor(paths, {
lastReviewedMessageId: args["message-id"] ?? null,
lastReviewedTimestamp: args.timestamp ?? null,
});
writeJson({
canonicalPath,
workingPath,
cursor: state.moderation_runtime?.cursor ?? null,
});
return;
}
case "record-delivery-receipt": {
const sentAt = args["sent-at"];
if (typeof sentAt !== "string" || sentAt.trim().length === 0) {
throw new Error("record-delivery-receipt requires --sent-at.");
}
const state = await recordModerationDeliveryReceipt(paths, {
sentAt,
mode: args.mode ?? null,
transport: args.transport ?? null,
channelId: args["channel-id"] ?? null,
replyToMessageId: args["reply-to-message-id"] ?? null,
personaName: args["persona-name"] ?? null,
personaAvatarUrl: args["persona-avatar-url"] ?? null,
contentLength: args["content-length"] ? Number.parseInt(args["content-length"], 10) : null,
chunkCount: args["chunk-count"] ? Number.parseInt(args["chunk-count"], 10) : null,
preview: args.preview ?? null,
});
writeJson({
canonicalPath,
workingPath,
cursor: state.moderation_runtime?.cursor ?? null,
recentDeliveryReceipts: state.moderation_runtime?.recent_delivery_receipts ?? [],
});
return;
}
default:
throw new Error(`Unknown moderation-state-store command "${command}".`);
}

View file

@ -23,6 +23,7 @@ $mcpServerPath = Join-Path $repoRoot "apps\worker\dist\mcp-server.js"
$recentHistoryScriptPath = Join-Path $repoRoot "scripts\export-recent-discord-history.mjs"
$moderationContextScriptPath = Join-Path $repoRoot "scripts\export-moderation-context.mjs"
$stateStoreScriptPath = Join-Path $repoRoot "scripts\moderation-state-store.mjs"
$selfStateScriptPath = Join-Path $repoRoot "scripts\void-self-state.mjs"
$startedAtUtc = [DateTime]::UtcNow
function Read-DotEnv {
@ -214,6 +215,27 @@ function Invoke-NodeJson {
return $output | ConvertFrom-Json
}
function Add-JsonPropertyIfPresent {
param(
[Parameter(Mandatory = $true)]
[hashtable] $Target,
[Parameter(Mandatory = $true)]
[string] $Name,
$Value
)
if ($null -eq $Value) {
return
}
$stringValue = [string]$Value
if ([string]::IsNullOrWhiteSpace($stringValue)) {
return
}
$Target[$Name] = $Value
}
trap {
$finishedAtUtc = [DateTime]::UtcNow
$failureMessage = $_.Exception.Message
@ -292,7 +314,8 @@ if (-not (Test-Path $mcpServerPath)) {
$scriptPreflight = @(
$recentHistoryScriptPath,
$moderationContextScriptPath,
$stateStoreScriptPath
$stateStoreScriptPath,
$selfStateScriptPath
)
foreach ($requiredPath in $scriptPreflight) {
@ -473,22 +496,23 @@ if ($exitCode -eq 0) {
))
if ($null -ne $observedCursor) {
$cursorArgs = @(
$stateStoreScriptPath,
"set-cursor",
"--canonical", $stateFilePath,
"--working", $stateWorkingPath
)
if (
-not [string]::IsNullOrWhiteSpace([string]$observedCursor.lastReviewedMessageId) -and
-not [string]::IsNullOrWhiteSpace([string]$observedCursor.lastReviewedTimestamp)
) {
$cursorOperation = @{
operation = "record_reviewed_messages"
lastReviewedMessageId = [string]$observedCursor.lastReviewedMessageId
lastReviewedTimestamp = [string]$observedCursor.lastReviewedTimestamp
} | ConvertTo-Json -Compress
if (-not [string]::IsNullOrWhiteSpace([string]$observedCursor.lastReviewedMessageId)) {
$cursorArgs += @("--message-id", [string]$observedCursor.lastReviewedMessageId)
[void](Invoke-NodeJson -Arguments @(
$selfStateScriptPath,
"apply-operation",
"--canonical", $stateFilePath,
"--operation", $cursorOperation
))
}
if (-not [string]::IsNullOrWhiteSpace([string]$observedCursor.lastReviewedTimestamp)) {
$cursorArgs += @("--timestamp", [string]$observedCursor.lastReviewedTimestamp)
}
[void](Invoke-NodeJson -Arguments $cursorArgs)
}
if ((-not $NoPost) -and (Test-Path (Join-Path $statusDir "void-last-speech.json"))) {
@ -498,24 +522,35 @@ if ($exitCode -eq 0) {
$receiptSentAt = [DateTime]::Parse([string]$speechReceipt.sentAt).ToUniversalTime()
if ($receiptSentAt -ge $startedAtUtc.AddSeconds(-5) -and $receiptSentAt -le $finishedAtUtc.AddMinutes(1)) {
$receiptArgs = @(
$stateStoreScriptPath,
"record-delivery-receipt",
"--canonical", $stateFilePath,
"--working", $stateWorkingPath,
"--sent-at", [string]$speechReceipt.sentAt,
"--mode", [string]$speechReceipt.mode,
"--transport", [string]$speechReceipt.transport,
"--channel-id", [string]$speechReceipt.channelId,
"--reply-to-message-id", [string]$speechReceipt.replyToMessageId,
"--persona-name", [string]$speechReceipt.personaName,
"--persona-avatar-url", [string]$speechReceipt.personaAvatarUrl,
"--content-length", [string]$speechReceipt.contentLength,
"--chunk-count", [string]$speechReceipt.chunkCount,
"--preview", [string]$speechReceipt.preview
)
$receipt = @{
receiptKey = "speech-{0}-{1}-{2}" -f ([string]$speechReceipt.sentAt), ([string]$speechReceipt.channelId), ([string]$speechReceipt.replyToMessageId)
sentAt = [string]$speechReceipt.sentAt
}
Add-JsonPropertyIfPresent -Target $receipt -Name "mode" -Value $speechReceipt.mode
Add-JsonPropertyIfPresent -Target $receipt -Name "transport" -Value $speechReceipt.transport
Add-JsonPropertyIfPresent -Target $receipt -Name "channelId" -Value $speechReceipt.channelId
Add-JsonPropertyIfPresent -Target $receipt -Name "replyToMessageId" -Value $speechReceipt.replyToMessageId
Add-JsonPropertyIfPresent -Target $receipt -Name "personaName" -Value $speechReceipt.personaName
Add-JsonPropertyIfPresent -Target $receipt -Name "personaAvatarUrl" -Value $speechReceipt.personaAvatarUrl
if ($null -ne $speechReceipt.contentLength) {
$receipt["contentLength"] = [int]$speechReceipt.contentLength
}
if ($null -ne $speechReceipt.chunkCount) {
$receipt["chunkCount"] = [int]$speechReceipt.chunkCount
}
Add-JsonPropertyIfPresent -Target $receipt -Name "preview" -Value $speechReceipt.preview
[void](Invoke-NodeJson -Arguments $receiptArgs)
$receiptOperation = @{
operation = "record_delivery_receipt"
receipt = $receipt
} | ConvertTo-Json -Compress -Depth 8
[void](Invoke-NodeJson -Arguments @(
$selfStateScriptPath,
"apply-operation",
"--canonical", $stateFilePath,
"--operation", $receiptOperation
))
}
}
}

View file

@ -93,3 +93,4 @@
{"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."}
{"ts":"2026-05-16T11:09:34Z","type":"implementation","status":"ok","note":"Repo activity cursor writes now go through scripts/void-self-state.mjs apply-operation with update_repo_activity_cursor instead of editing moderation-agent-state.json and committing the whole projection. export-recent-repo-activity still accepts old --cursor-file JSON paths by deriving the sibling .msgpack path, but the runner/doctrine now point at --state-path .voidbot/private/moderation-agent-state.msgpack. Temp-state repo sweep smoke passed."}
{"ts":"2026-05-16T11:14:12Z","type":"implementation","status":"ok","note":"Moderation runner now records reviewed cursor and delivered speech receipts through typed self-state operations instead of moderation-state-store mutation commands. The old set-cursor and record-delivery-receipt commands/functions were removed from the legacy moderation-state-store surface. Receipt operation smoke verifies it records one receipt and closes the matching open case."}

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, read-only projection layer, operation mutation CLI/service, and repo-activity cursor writer now exist. Next move delivered-reply receipts behind the same operation path, 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.
next_action: The typed CultCache self-state document vocabulary, read-only projection layer, operation mutation CLI/service, repo-activity cursor writer, reviewed cursor writer, and delivered-reply receipt writer now exist. Next replace whole-state agent JSON edits with operation output from the unattended moderation runner. 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.