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
26 changes: 25 additions & 1 deletion dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { parseReflectionMetadata } from "./src/reflection-metadata.js";
import { extractReflectionLearningGovernanceCandidates, extractInjectableReflectionMappedMemoryItems, isRecallUsed, } from "./src/reflection-slices.js";
import { createReflectionEventId } from "./src/reflection-event-store.js";
import { buildReflectionMappedMetadata } from "./src/reflection-mapped-metadata.js";
import { gateMappedReflectionEntry } from "./src/reflection-mapped-admission.js";
import { createMemoryCLI } from "./cli.js";
import { isNoise } from "./src/noise-filter.js";
import { normalizeAutoCaptureText } from "./src/auto-capture-cleanup.js";
Expand Down Expand Up @@ -1008,7 +1009,7 @@ async function ensureDailyLogFile(dailyPath, dateStr) {
await writeFile(dailyPath, `# ${dateStr}\n\n`, "utf-8");
}
}
function buildReflectionPrompt(conversation, maxInputChars, toolErrorSignals = []) {
export function buildReflectionPrompt(conversation, maxInputChars, toolErrorSignals = []) {
const clipped = conversation.slice(-maxInputChars);
const errorHints = toolErrorSignals.length > 0
? toolErrorSignals
Expand Down Expand Up @@ -1039,6 +1040,7 @@ function buildReflectionPrompt(conversation, maxInputChars, toolErrorSignals = [
"- Do not wrap one bullet across multiple lines.",
"- If a bullet section is empty, write exactly: '- (none captured)'",
"- Do not paste raw transcript.",
"- Grounding: treat claims made inside roleplay, games, fiction, hypotheticals, or test/simulation frames as not real. Such content may be summarized in Context or Open loops, but must NEVER appear under Decisions (durable), User model deltas, Agent model deltas, or Lessons & pitfalls \u2014 those sections become durable memory rows.",
"- Do not invent Logged timestamps, ids, file paths, commit hashes, session ids, or storage metadata unless they already appear in the input.",
"- If secrets/tokens/passwords appear, keep them as [REDACTED].",
"",
Expand Down Expand Up @@ -3848,6 +3850,25 @@ const memoryLanceDBProPlugin = {
if (existing.length > 0 && existing[0].score > 0.95) {
continue;
}
// Writer-1 admission routing: mapped rows previously bypassed
// admission control entirely. Gate each row through the same
// AdmissionController as extraction candidates; passthrough when
// admission control (or smart extraction) is disabled.
const mappedGate = await gateMappedReflectionEntry({
admissionController: smartExtractor?.getAdmissionController() ?? null,
attachAudit: smartExtractor?.shouldPersistAdmissionAudit() ?? false,
text: mapped.text,
category: mapped.category,
heading: mapped.heading,
vector,
reflectionText,
scopeFilter: [targetScope],
warnLog: (msg) => api.logger.warn(msg),
});
if (!mappedGate.admit) {
api.logger.info(`memory-reflection: admission rejected mapped row heading=${JSON.stringify(mapped.heading)} provenance=memory-reflection-mapped: ${mappedGate.reason ?? "no reason"}`);
continue;
}
const importance = mapped.category === "decision" ? 0.85 : 0.8;
const baseMetadata = buildReflectionMappedMetadata({
mappedItem: mapped,
Expand All @@ -3862,6 +3883,9 @@ const memoryLanceDBProPlugin = {
});
// embed heading in metadata JSON so it survives bulkStore round-trip to LanceDB
baseMetadata._reflectionHeading = mapped.heading;
if (mappedGate.auditJson) {
baseMetadata.admission_audit = mappedGate.auditJson;
}
const metadata = JSON.stringify(baseMetadata);
mappedEntries.push({
text: mapped.text,
Expand Down
76 changes: 76 additions & 0 deletions dist/src/reflection-mapped-admission.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Admission gating for reflection writer-1 (mapped rows).
*
* The reflection distiller's mapped sections (User model deltas, Agent model
* deltas, Lessons & pitfalls, Decisions) become ordinary durable memory rows
* via bulkStore. Historically they bypassed admission control entirely, so a
* contaminated or hallucinated distillate line landed as a confirmed memory
* with no gate and no audit trail. This module routes each mapped row through
* the same AdmissionController evaluation extraction candidates get, while
* preserving passthrough behavior when admission control is disabled.
*/
/**
* Admission typePriors are keyed by the six smart registers, but mapped rows
* carry legacy store categories. Score them under the smart register that
* matches their shape: user-model/agent-model deltas are preference-shaped
* statements about the human or the assistant ("preference"), lessons are
* symptom/cause/fix/prevention pairs ("fact" here, cases-shaped), and
* decisions are episodic records of something decided ("events").
*/
export function mapReflectionMappedCategoryToSmartRegister(category) {
switch (category) {
case "preference":
return "preferences";
case "fact":
return "cases";
case "decision":
return "events";
default:
return "events";
}
}
/**
* Gate one mapped reflection row through admission control.
*
* - No controller (admission disabled or smart extraction off): passthrough,
* identical to the historical behavior.
* - Controller reject: the row is dropped; the caller logs the reason.
* - Controller pass: the row proceeds; when `attachAudit` is set the audit
* record (tagged with provenance "memory-reflection-mapped") is returned
* for persistence alongside the row.
* - Infra failure inside evaluation: fail open with a reason, so a transient
* store/LLM error cannot silently suppress reflection persistence.
*/
export async function gateMappedReflectionEntry(params) {
if (!params.admissionController) {
return { admit: true };
}
const smartCategory = mapReflectionMappedCategoryToSmartRegister(params.category);
let evaluation;
try {
evaluation = await params.admissionController.evaluate({
candidate: {
category: smartCategory,
abstract: params.text,
overview: `## ${params.heading}`,
content: params.text,
},
candidateVector: params.vector,
conversationText: params.reflectionText,
scopeFilter: params.scopeFilter,
});
}
catch (err) {
params.warnLog?.(`memory-reflection: mapped-row admission evaluation failed, admitting without audit: ${String(err)}`);
return { admit: true, reason: "admission evaluation failed open" };
}
if (evaluation.decision === "reject") {
return { admit: false, reason: evaluation.audit.reason };
}
return {
admit: true,
auditJson: params.attachAudit
? JSON.stringify({ ...evaluation.audit, provenance: "memory-reflection-mapped" })
: undefined,
};
}
4 changes: 4 additions & 0 deletions dist/src/reflection-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,10 @@ function resolveLegacyDeriveBaseWeight(metadata) {
}
return 1;
}
// NOTE: this reader currently has no production callers (only tests import
// it). Mapped rows written by the reflection writer are consumed exclusively
// through generic semantic recall today; the dedicated injection path this
// reader was built for was never wired up. Kept for that planned wiring.
export function loadReflectionMappedRowsFromEntries(params) {
const now = Number.isFinite(params.now) ? Number(params.now) : Date.now();
const maxAgeMs = Number.isFinite(params.maxAgeMs)
Expand Down
12 changes: 12 additions & 0 deletions dist/src/smart-extractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,18 @@ export class SmartExtractor {
? new AdmissionController(this.store, this.llm, config.admissionControl, this.debugLog)
: null;
}
/**
* Expose the admission controller so sibling write paths (reflection
* mapped rows) gate through the same instance and config as extraction
* candidates. Null when admission control is disabled.
*/
getAdmissionController() {
return this.admissionController;
}
/** Whether admitted entries should carry the admission audit in metadata. */
shouldPersistAdmissionAudit() {
return this.persistAdmissionAudit;
}
/**
* Notify the onPersisted sink (e.g. markdown mirror) after a successful
* create or merge. Fire-and-forget from the caller's perspective: awaited
Expand Down
68 changes: 68 additions & 0 deletions docs/notes/reflection-write-path-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Reflection write-path findings

Findings from a code-plus-trace audit of the reflection persistence paths.
Kept here as discussion material; each item states what the code does today
and what remains open.

## Two writers, different gates

The reflection distiller output is persisted by two independent writers:

1. **Writer 1 (mapped rows).** Four headings of the reflection markdown
(User model deltas, Agent model deltas, Lessons & pitfalls, Decisions)
are parsed into individual rows (`extractInjectableReflectionMappedMemoryItems`)
and bulk-stored as ordinary memories with `metadata.type =
"memory-reflection-mapped"`. Until the accompanying change, this path had
no admission gate and no gating knob; `reflectionStoreToLanceDB` gates
only writer 2. Mapped rows now route through the same AdmissionController
as extraction candidates when admission control is enabled (passthrough
when disabled, fail-open on infra errors). Scoring category mapping:
`preference` (user/agent model deltas) scores as `preferences`, `fact`
(lessons) as `cases`, `decision` as `events`.

2. **Writer 2 (reflection documents).** `storeReflectionToLanceDB` persists
sliced reflection documents under category `reflection`, gated by the
`reflectionStoreToLanceDB` config knob. Unchanged by this work.

## Dead reader

`loadReflectionMappedRowsFromEntries` (reflection-store) was built to read
mapped rows back for a dedicated injection path, but it has no production
callers (only a test imports it). The only living read path for mapped rows
is generic semantic recall. A code comment now marks this. Open question:
either wire the dedicated reader or consider retiring mapped rows in favor
of writer 2 plus recall.

## Cross-writer double-store

"Decisions (durable)" bullets can be persisted twice: once by writer 1 as a
`decision` row, and once inside writer 2's document slices (invariant
fallback). There is no cross-lane dedup between the two writers. Any future
dedup would need to compare across `metadata.type` values.

## memory_layer parity question

The auto-recall governance filter drops entries with `memory_layer` in
`{archive, reflection}`, but only the dreaming engine sets layer
`reflection`. Mapped rows carry layer semantics of ordinary confirmed
memories, so they pass recall filters that reflection-layer content does
not. If mapped rows are conceptually reflection output, they may deserve
the same layer tag; if they are conceptually ordinary memories, the
admission gate added here is the appropriate control. Left as a design
question.

## Auto-capture watermark

The per-session auto-capture cursor (`autoCaptureSeenTextCount`) is reset
to zero after every successful smart extraction, which makes the next
capture of the same session re-read the whole history instead of the
delta. Analysis and fix (recording the consumed length instead) are on the
dedicated watermark fix branch.

## Residual gap

The mapped-row admission gate reuses the SmartExtractor's controller
instance. When smart extraction is disabled while admission control is
enabled, mapped rows still pass ungated (no controller exists to borrow).
This mirrors the pre-change behavior and is called out here for
completeness.
29 changes: 28 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
} from "./src/reflection-slices.js";
import { createReflectionEventId } from "./src/reflection-event-store.js";
import { buildReflectionMappedMetadata } from "./src/reflection-mapped-metadata.js";
import { gateMappedReflectionEntry } from "./src/reflection-mapped-admission.js";
import { createMemoryCLI } from "./cli.js";
import { isNoise } from "./src/noise-filter.js";
import { normalizeAutoCaptureText } from "./src/auto-capture-cleanup.js";
Expand Down Expand Up @@ -1455,7 +1456,7 @@ async function ensureDailyLogFile(dailyPath: string, dateStr: string): Promise<v
}
}

function buildReflectionPrompt(
export function buildReflectionPrompt(
conversation: string,
maxInputChars: number,
toolErrorSignals: ReflectionErrorSignal[] = []
Expand Down Expand Up @@ -1490,6 +1491,7 @@ function buildReflectionPrompt(
"- Do not wrap one bullet across multiple lines.",
"- If a bullet section is empty, write exactly: '- (none captured)'",
"- Do not paste raw transcript.",
"- Grounding: treat claims made inside roleplay, games, fiction, hypotheticals, or test/simulation frames as not real. Such content may be summarized in Context or Open loops, but must NEVER appear under Decisions (durable), User model deltas, Agent model deltas, or Lessons & pitfalls \u2014 those sections become durable memory rows.",
"- Do not invent Logged timestamps, ids, file paths, commit hashes, session ids, or storage metadata unless they already appear in the input.",
"- If secrets/tokens/passwords appear, keep them as [REDACTED].",
"",
Expand Down Expand Up @@ -4925,6 +4927,28 @@ const memoryLanceDBProPlugin = {
continue;
}

// Writer-1 admission routing: mapped rows previously bypassed
// admission control entirely. Gate each row through the same
// AdmissionController as extraction candidates; passthrough when
// admission control (or smart extraction) is disabled.
const mappedGate = await gateMappedReflectionEntry({
admissionController: smartExtractor?.getAdmissionController() ?? null,
attachAudit: smartExtractor?.shouldPersistAdmissionAudit() ?? false,
text: mapped.text,
category: mapped.category,
heading: mapped.heading,
vector,
reflectionText,
scopeFilter: [targetScope],
warnLog: (msg: string) => api.logger.warn(msg),
});
if (!mappedGate.admit) {
api.logger.info(
`memory-reflection: admission rejected mapped row heading=${JSON.stringify(mapped.heading)} provenance=memory-reflection-mapped: ${mappedGate.reason ?? "no reason"}`,
);
continue;
}

const importance = mapped.category === "decision" ? 0.85 : 0.8;
const baseMetadata = buildReflectionMappedMetadata({
mappedItem: mapped,
Expand All @@ -4939,6 +4963,9 @@ const memoryLanceDBProPlugin = {
});
// embed heading in metadata JSON so it survives bulkStore round-trip to LanceDB
baseMetadata._reflectionHeading = mapped.heading;
if (mappedGate.auditJson) {
baseMetadata.admission_audit = mappedGate.auditJson;
}
const metadata = JSON.stringify(baseMetadata);

mappedEntries.push({
Expand Down
Loading
Loading