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
2 changes: 2 additions & 0 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2478,6 +2478,7 @@ const memoryLanceDBProPlugin = {
mdMirror,
workspaceBoundary: config.workspaceBoundary,
selfImprovementMaxEntries: config.selfImprovement?.maxEntries,
manualStoreSupersede: config.manualStoreSupersede === true,
// Mirrors the CLI context wiring below: keep in-process reflection caches
// consistent after a live memory_forget delete too, not just CLI delete/delete-bulk.
onMemoriesDeleted: ({ scopeFilter }) => invalidateReflectionCachesAfterDelete(scopeFilter),
Expand Down Expand Up @@ -5236,6 +5237,7 @@ export function parsePluginConfig(value) {
batchChunkSize: (() => { const raw = parsePositiveInt(cfg.batchChunkSize); return raw === undefined ? undefined : Math.min(50, raw); })(),
scopes: typeof cfg.scopes === "object" && cfg.scopes !== null ? cfg.scopes : undefined,
enableManagementTools: cfg.enableManagementTools === true,
manualStoreSupersede: cfg.manualStoreSupersede === true,
sessionStrategy,
selfImprovement: typeof cfg.selfImprovement === "object" && cfg.selfImprovement !== null
? {
Expand Down
137 changes: 135 additions & 2 deletions dist/src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,58 @@ export class MemoryStore {
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
.slice(offset, offset + limit);
}
/**
* Bounded candidate scan for fact-key collision discovery. Unlike list(),
* which materializes and sorts the entire matching scope on every call,
* this pushes a hard row limit into the database query and never sorts.
* Deliberately NO content-based narrowing: an effective fact key can come
* from an explicit metadata field in any valid JSON layout (spaced colons,
* unicode escapes), from a stamped memory category in the same layouts, or
* be derived from the legacy storage category column plus row text when
* metadata is empty — so any serialization-layout pattern (LIKE on raw
* JSON) can exclude a valid candidate and silently break the collision
* set's completeness. Completeness therefore comes from the scope itself:
* every in-scope row is a candidate, the caller's bound keeps the scan
* finite, and an over-bound scope is an explicit rejection rather than a
* narrowed guess. Exact normalized-key comparison and active-row filtering
* stay with the caller.
* Returns at most bound + 1 rows so the caller can detect an over-bound
* candidate set without this method ever fetching an unbounded one.
*/
async listFactKeyCandidates(scopeFilter, bound) {
await this.ensureInitialized();
if (isExplicitDenyAllScopeFilter(scopeFilter))
return [];
const conditions = [];
if (scopeFilter.length > 0) {
const scopeConditions = scopeFilter
.map((scope) => `scope = '${escapeSqlLiteral(scope)}'`)
.join(" OR ");
conditions.push(`(${scopeConditions})`);
}
const applyConditions = (query) => conditions.length > 0
? query.where(conditions.join(" AND ")).limit(bound + 1)
: query.limit(bound + 1);
const results = await this.queryRowsWithProjectionFallback(applyConditions, [
"id",
"text",
"category",
"scope",
"importance",
"timestamp",
"metadata",
]);
return results.map((row) => ({
id: row.id,
text: row.text,
vector: [],
category: row.category,
scope: row.scope ?? "global",
importance: clampImportance(Number(row.importance)),
timestamp: normalizeMemoryTimestamp(row.timestamp, 0),
metadata: row.metadata || "{}",
}));
}
async queryRowsWithProjectionFallback(applyFilters, columns) {
const projectedRows = await applyFilters(this.table.query())
.select(columns)
Expand Down Expand Up @@ -2346,8 +2398,9 @@ export class MemoryStore {
}
/**
* The locked body of update(). Callers must already hold the write lock
* and the serialized-update slot; transformMetadata composes it with a
* fresh read-decide step under the same lock.
* and the serialized-update slot: update() wraps it, transformMetadata
* composes it with a fresh read-decide step under the same lock, and the
* supersede commit path calls it from inside its own atomic section.
*/
async performUpdateLocked(id, updates, scopeFilter) {
// Support full UUID, short hex prefixes, and constrained exact legacy IDs imported
Expand Down Expand Up @@ -2664,6 +2717,86 @@ export class MemoryStore {
this.noteDataModification();
return { repaired, failed, skipped, unrecovered };
}
/**
* Force the open table handle onto the latest committed version. A nonzero
* readConsistencyInterval lets reads serve a snapshot up to that many
* seconds stale; a locked read-modify-write section must observe every
* commit that preceded its lock acquisition, so it re-syncs first. A sync
* failure propagates: proceeding on a possibly-stale snapshot would
* silently reintroduce the staleness this guard exists to close.
*/
async syncTableToLatest() {
const table = this.table;
if (table && typeof table.checkoutLatest === "function") {
await table.checkoutLatest();
}
}
/**
* Atomic supersede-and-store: re-discovers the target rows, inserts the new
* row, and invalidates every confirmed target inside ONE write-lock +
* serialized-update section. The caller's advisory discovery only decides
* whether to enter this path; the target set that actually commits is the
* one discovered here, so two concurrent same-key writers converge on a
* single active row (the second writer's recheck sees the first writer's
* replacement and supersedes it) instead of leaving both replacements
* standing.
*
* Only CONFIRMED invalidations are reported in supersededIds; a null or
* throwing patch lands in invalidationFailures instead of being silently
* counted as success.
*/
async storeSuperseding(options) {
await this.ensureInitialized();
const result = await this.runWithWriteLock(() => this.runSerializedUpdate(async () => {
// The cross-process lock serializes writers but does not refresh this
// handle's read snapshot: with a second store instance and a nonzero
// readConsistencyInterval, the locked re-discovery could miss the
// preceding writer's commit and leave both replacements active.
await this.syncTableToLatest();
const targets = await options.discoverTargets();
const fullEntry = {
...options.entry,
id: randomUUID(),
timestamp: Date.now(),
metadata: options.finalizeEntryMetadata
? options.finalizeEntryMetadata(targets)
: options.entry.metadata || "{}",
importance: clampImportance(Number(options.entry.importance)),
};
await this.table.add([fullEntry]);
const supersededIds = [];
const invalidationFailures = [];
for (const target of targets) {
try {
const existing = await this.getById(target.id, options.scopeFilter);
if (!existing) {
invalidationFailures.push({
id: target.id,
reason: "row not found or outside accessible scopes at commit time",
});
continue;
}
const metadata = buildSmartMetadata(existing, options.buildTargetPatch(existing, fullEntry.id));
const updated = await this.performUpdateLocked(target.id, { metadata: stringifySmartMetadata(metadata) }, options.scopeFilter);
if (updated == null) {
invalidationFailures.push({ id: target.id, reason: "update persisted no row" });
}
else {
supersededIds.push(target.id);
}
}
catch (err) {
invalidationFailures.push({
id: target.id,
reason: err instanceof Error ? err.message : String(err),
});
}
}
return { entry: fullEntry, supersededIds, invalidationFailures };
}));
this.noteDataModification();
return result;
}
async bulkDelete(scopeFilter, beforeTimestamp) {
await this.ensureInitialized();
const conditions = [];
Expand Down
Loading
Loading