Skip to content
Merged
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
29 changes: 28 additions & 1 deletion cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1978,6 +1978,10 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void {
.option("--no-llm", "Skip LLM calls; use simple text truncation for L0/L1")
.option("--limit <n>", "Maximum number of memories to upgrade")
.option("--scope <scope>", "Only upgrade memories in this scope")
.option(
"--categories-only",
"Only re-stamp memory_category on reflection-mapped rows (skip the general legacy L0/L1/L2 upgrade)",
)
.action(async (options) => {
try {
const upgrader = createMemoryUpgrader(
Expand All @@ -1986,8 +1990,31 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void {
{ log: console.log },
);

// Show current status first
const scopeFilter = options.scope ? [options.scope] : undefined;

if (options.categoriesOnly) {
const result = await upgrader.normalizeMappedRowCategories({
dryRun: !!options.dryRun,
scopeFilter,
});

console.log(`Mapped-Row Category Normalization:`);
console.log(`• Reflection-mapped rows scanned: ${result.totalMapped}`);
console.log(`• Already correct: ${result.alreadyCorrect}`);
console.log(
`${options.dryRun ? "• [DRY-RUN] Would normalize" : "• Normalized"}: ${result.normalized}`,
);
if (result.errors.length > 0) {
console.log(`• Errors: ${result.errors.length}`);
result.errors.slice(0, 5).forEach(err => console.log(` - ${err}`));
if (result.errors.length > 5) {
console.log(` ... and ${result.errors.length - 5} more`);
}
}
return;
}

// Show current status first
const counts = await upgrader.countLegacy(scopeFilter);

console.log(`Memory Upgrade Status:`);
Expand Down
21 changes: 20 additions & 1 deletion dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1632,11 +1632,30 @@ export function registerMemoryCLI(program, context) {
.option("--no-llm", "Skip LLM calls; use simple text truncation for L0/L1")
.option("--limit <n>", "Maximum number of memories to upgrade")
.option("--scope <scope>", "Only upgrade memories in this scope")
.option("--categories-only", "Only re-stamp memory_category on reflection-mapped rows (skip the general legacy L0/L1/L2 upgrade)")
.action(async (options) => {
try {
const upgrader = createMemoryUpgrader(context.store, options.llm === false ? null : (context.llmClient ?? null), { log: console.log });
// Show current status first
const scopeFilter = options.scope ? [options.scope] : undefined;
if (options.categoriesOnly) {
const result = await upgrader.normalizeMappedRowCategories({
dryRun: !!options.dryRun,
scopeFilter,
});
console.log(`Mapped-Row Category Normalization:`);
console.log(`• Reflection-mapped rows scanned: ${result.totalMapped}`);
console.log(`• Already correct: ${result.alreadyCorrect}`);
console.log(`${options.dryRun ? "• [DRY-RUN] Would normalize" : "• Normalized"}: ${result.normalized}`);
if (result.errors.length > 0) {
console.log(`• Errors: ${result.errors.length}`);
result.errors.slice(0, 5).forEach(err => console.log(` - ${err}`));
if (result.errors.length > 5) {
console.log(` ... and ${result.errors.length - 5} more`);
}
}
return;
}
// Show current status first
const counts = await upgrader.countLegacy(scopeFilter);
console.log(`Memory Upgrade Status:`);
console.log(`• Total memories: ${counts.total}`);
Expand Down
8 changes: 4 additions & 4 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { storeReflectionToLanceDB, loadAgentReflectionSlicesFromEntries, DEFAULT
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 { buildReflectionMappedMetadata, getReflectionMappedStorageCategory } from "./src/reflection-mapped-metadata.js";
import { gateMappedReflectionEntries } from "./src/reflection-mapped-admission.js";
import { createMemoryCLI } from "./cli.js";
import { isNoise } from "./src/noise-filter.js";
Expand Down Expand Up @@ -3988,7 +3988,7 @@ const memoryLanceDBProPlugin = {
attachAudit: smartExtractor?.shouldPersistAdmissionAudit() ?? false,
rows: gateEligible.map(({ mapped, vector }) => ({
text: mapped.text,
category: mapped.category,
mappedKind: mapped.mappedKind,
heading: mapped.heading,
vector,
})),
Expand All @@ -4007,7 +4007,7 @@ const memoryLanceDBProPlugin = {
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 importance = mapped.mappedKind === "decision" ? 0.85 : 0.8;
const baseMetadata = buildReflectionMappedMetadata({
mappedItem: mapped,
eventId: reflectionEventId,
Expand All @@ -4029,7 +4029,7 @@ const memoryLanceDBProPlugin = {
text: mapped.text,
vector,
importance,
category: mapped.category,
category: getReflectionMappedStorageCategory(mapped.mappedKind),
scope: targetScope,
metadata,
});
Expand Down
113 changes: 113 additions & 0 deletions dist/src/memory-upgrader.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
* 4. Write prepared patches in a batch where the store supports it
*/
import { buildSmartMetadata, stringifySmartMetadata } from "./smart-metadata.js";
import { getReflectionMappedMemoryCategory, getReflectionMappedStorageCategory, } from "./reflection-mapped-metadata.js";
function isReflectionMappedKind(value) {
return (value === "user-model" ||
value === "agent-model" ||
value === "lesson" ||
value === "decision");
}
const CURRENT_REFLECTION_METADATA_TYPES = new Set([
"memory-reflection",
"memory-reflection-event",
Expand Down Expand Up @@ -158,6 +165,110 @@ export class MemoryUpgrader {
}
return { total: allMemories.length, legacy, byCategory };
}
/**
* One-shot, opt-in pass that re-stamps `memory_category` on existing
* reflection-mapped rows using the same write-time mapping new rows get
* (see `getReflectionMappedMemoryCategory`). Reflection-mapped rows are
* intentionally excluded from `isLegacyMemory`/`upgrade()` — this is a
* separate, narrower pass that touches only that one field on rows whose
* `type` is `memory-reflection-mapped`, and only when the stamped value is
* missing or wrong. Safe to run repeatedly: a row already carrying the
* correct value is left untouched, so a second run is a no-op.
*/
async normalizeMappedRowCategories(options = {}) {
const dryRun = options.dryRun ?? false;
const scopeFilter = options.scopeFilter;
const pageSize = Math.max(1, options.pageSize ?? 1000);
const result = {
totalMapped: 0,
normalized: 0,
alreadyCorrect: 0,
errors: [],
};
// Phase 1 — paged scan. Pages the whole store (list sorts newest-first;
// no single-page cap), keeping only ids plus the scan-time snapshot as a
// fallback payload. A row is "already correct" only when BOTH faces hold:
// the stamped metadata value and the legacy-vocabulary storage column.
const targets = [];
for (let offset = 0;; offset += pageSize) {
const page = await this.store.list(scopeFilter, undefined, pageSize, offset);
for (const entry of page) {
const meta = parseMetadata(entry.metadata);
if (!meta || meta.type !== "memory-reflection-mapped")
continue;
if (!isReflectionMappedKind(meta.mappedKind))
continue;
result.totalMapped++;
const expected = getReflectionMappedMemoryCategory(meta.mappedKind);
const expectedStorage = getReflectionMappedStorageCategory(meta.mappedKind);
if (meta.memory_category === expected && entry.category === expectedStorage) {
result.alreadyCorrect++;
continue;
}
targets.push({ entry, meta });
}
if (page.length < pageSize)
break;
}
if (dryRun || targets.length === 0) {
result.normalized = targets.length;
return result;
}
// Phase 2 — chunked fresh-read + write. The store's update paths replace
// metadata all-or-nothing, so a patch built from the scan snapshot would
// silently roll back any concurrent metadata write (access counters,
// admission audits, tier changes) that landed after the scan. Re-reading
// each row immediately before building its patch shrinks that window from
// scan-to-write to per-chunk milliseconds; stores without getById fall
// back to the scan snapshot (test doubles, minimal adapters).
const storeWithGetById = this.store;
const canRefetch = typeof storeWithGetById.getById === "function";
const chunkSize = 100;
for (let start = 0; start < targets.length; start += chunkSize) {
const chunk = targets.slice(start, start + chunkSize);
const prepared = [];
for (const target of chunk) {
let entry = target.entry;
let meta = target.meta;
if (canRefetch) {
try {
const fresh = await storeWithGetById.getById(target.entry.id, scopeFilter);
if (!fresh)
continue; // deleted since the scan — nothing to normalize
const freshMeta = parseMetadata(fresh.metadata);
if (!freshMeta || freshMeta.type !== "memory-reflection-mapped")
continue;
if (!isReflectionMappedKind(freshMeta.mappedKind))
continue;
entry = fresh;
meta = freshMeta;
}
catch (err) {
result.errors.push(`re-read failed for ${target.entry.id}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
}
const expected = getReflectionMappedMemoryCategory(meta.mappedKind);
const expectedStorage = getReflectionMappedStorageCategory(meta.mappedKind);
if (meta.memory_category === expected && entry.category === expectedStorage) {
result.alreadyCorrect++;
continue;
}
const updates = {
metadata: JSON.stringify({ ...meta, memory_category: expected }),
};
if (entry.category !== expectedStorage) {
updates.category = expectedStorage;
}
prepared.push({ entry, updates });
}
const writeResult = { upgraded: 0, errors: [] };
await this.writePreparedBatch(prepared, writeResult, scopeFilter);
result.normalized += writeResult.upgraded;
result.errors.push(...writeResult.errors);
}
return result;
}
/**
* Main upgrade entry point.
* Scans all memories, filters legacy ones, and enriches them.
Expand Down Expand Up @@ -298,6 +409,8 @@ export class MemoryUpgrader {
}
/**
* Persist a prepared batch with one store-level batch call when available.
* Takes the narrow slice of the result shape it actually mutates so both
* `UpgradeResult` and `CategoryNormalizationResult` can reuse it.
*/
async writePreparedBatch(prepared, result, scopeFilter) {
if (prepared.length === 0)
Expand Down
8 changes: 8 additions & 0 deletions dist/src/reflection-item-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export function buildReflectionItemPayloads(params) {
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
// Write-time L0/L1/L2: an item row is one distilled line, so the line is
// its own abstract and content; the section heading is the one piece of
// extra context worth an overview. Level-less item rows fell back to
// three identical lines in every shared pipeline prompt (same fix as
// reflection-mapped-metadata).
l0_abstract: item.text,
l1_overview: `## ${item.section}\n- ${item.text}`,
l2_content: item.text,
storedAt: params.runAt,
usedFallback: params.usedFallback,
errorSignals: params.toolErrorSignals.map((signal) => signal.signatureHash),
Expand Down
29 changes: 7 additions & 22 deletions dist/src/reflection-mapped-admission.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,15 @@
* reasons, and audit records are identical either way — only the LLM call
* topology differs.
*/
/**
* 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";
}
}
import { getReflectionMappedMemoryCategory, } from "./reflection-mapped-metadata.js";
function buildGateItem(row, conversationText, scopeFilter) {
return {
candidate: {
category: mapReflectionMappedCategoryToSmartRegister(row.category),
// Admission typePriors are keyed by the six smart registers. Scoring
// reads the SAME kind→category table the persisted memory_category
// stamp comes from (reflection-mapped-metadata.ts) so the register a
// row is judged under always matches the register it is stored under.
category: getReflectionMappedMemoryCategory(row.mappedKind),
abstract: row.text,
overview: `## ${row.heading}`,
content: row.text,
Expand Down Expand Up @@ -150,7 +135,7 @@ export async function gateMappedReflectionEntry(params) {
rows: [
{
text: params.text,
category: params.category,
mappedKind: params.mappedKind,
heading: params.heading,
vector: params.vector,
},
Expand Down
42 changes: 42 additions & 0 deletions dist/src/reflection-mapped-metadata.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getStorageCategoryForMemoryCategory, } from "./memory-categories.js";
const REFLECTION_MAPPED_DECAY_DEFAULTS = {
decision: { midpointDays: 45, k: 0.25, baseWeight: 1.1, quality: 1 },
"user-model": { midpointDays: 21, k: 0.3, baseWeight: 1, quality: 0.95 },
Expand All @@ -7,6 +8,39 @@ const REFLECTION_MAPPED_DECAY_DEFAULTS = {
export function getReflectionMappedDecayDefaults(kind) {
return REFLECTION_MAPPED_DECAY_DEFAULTS[kind];
}
/**
* mappedKind is known structurally at write time (each kind comes from a
* fixed reflection section), so the 6-category classification is a direct
* lookup rather than a text-sniffing heuristic. This map is the SINGLE
* source of the reflection heading→taxonomy mapping: metadata stamps, the
* stored row category, and admission scoring all read it. "decision" and
* "lesson" both land in "cases" — durable operational facts, not one-off
* "events" — which is what kept mapped decision rows shielded from
* consolidation before this stamp existed.
*/
const REFLECTION_MAPPED_MEMORY_CATEGORY = {
"user-model": "preferences",
// Agent self-observations are reusable assistant behavior, not statements
// about the human -- minting them as user "preferences" polluted recall
// and consolidation with rows that read as the user's own tendencies.
"agent-model": "patterns",
lesson: "cases",
decision: "cases",
};
export function getReflectionMappedMemoryCategory(kind) {
return REFLECTION_MAPPED_MEMORY_CATEGORY[kind];
}
/**
* The stored row's `category` column speaks the legacy storage vocabulary
* (MemoryEntry["category"]); the six-category taxonomy value lives only in
* `metadata.memory_category`. Deriving the column through the central
* smart-to-storage mapping keeps every direct consumer of the column
* (compaction's plurality vote, read-time reverse mapping, category filters)
* on values it actually understands.
*/
export function getReflectionMappedStorageCategory(kind) {
return getStorageCategoryForMemoryCategory(REFLECTION_MAPPED_MEMORY_CATEGORY[kind]);
}
export function buildReflectionMappedMetadata(params) {
const defaults = getReflectionMappedDecayDefaults(params.mappedItem.mappedKind);
return {
Expand All @@ -17,6 +51,14 @@ export function buildReflectionMappedMetadata(params) {
eventId: params.eventId,
mappedKind: params.mappedItem.mappedKind,
mappedCategory: params.mappedItem.category,
memory_category: getReflectionMappedMemoryCategory(params.mappedItem.mappedKind),
// Write-time L0/L1/L2: a mapped row is one distilled line, so the line is
// its own abstract and content; the distillate section heading is the one
// piece of extra context worth an overview. Level-less mapped rows used to
// render as three identical fallback lines in every shared pipeline prompt.
l0_abstract: params.mappedItem.text,
l1_overview: `## ${params.mappedItem.heading}\n- ${params.mappedItem.text}`,
l2_content: params.mappedItem.text,
section: params.mappedItem.heading,
ordinal: params.mappedItem.ordinal,
groupSize: params.mappedItem.groupSize,
Expand Down
Loading
Loading